Request lifecycle
A request traced from the Cloudflare edge to the response - route inclusion, the tracking wrapper, identity, plan and quota gates, the cache layers, the provider call and the telemetry writes.
Last updated 2026-08-06
Summary#
This page follows one metered tool run from the moment a packet reaches Cloudflare to the moment the last telemetry row is written. It names every checkpoint, the order they run in, what each one can return, and which of them charge the customer.
Two facts drive everything else. First, the quota gate runs even on a cache hit: a served-from-cache result still costs the customer their full credit, because they ran a report. Only the provider bill is saved. Second, metering happens before the upstream call, so a request that returns 200 has always been charged and a request that is refused by a gate has never been charged.
Purpose#
Knowing this sequence is how you answer three recurring questions without guessing: why was this request charged, why did it return this exact status, and where do I put a new check so it runs at the right moment.
It is also the map you need before changing anything in the gate chain. The checks are not independent; several of them exist only because an earlier version allowed something through, and the comments in the code record each of those incidents.
Architecture#
The pipeline, top to bottom:
1 Cloudflare edge
_routes.json → 15 excluded paths bypass the worker entirely
2 export default.fetch
→ mvTrackedFetch(request, env, ctx, mvRouteRequest)
3 mvTrackedFetch
route descriptor lookup · JSON body buffer (≤24,000 B) · start timer
4 mvRouteRequest
OPTIONS short-circuit · billing routes · diagnostic gate · the if chain
5 Wrapper (premium AI only)
mvSaveAroundPremiumAI → shared cache read
6 Gates, in order
kill switch → reco gate → quota gate → metering
7 Providers
DataForSEO (cached) → LLM
8 Post-processing
real-data honesty check · year scrub · data-source stamps
9 Persistence
mv_seo_cache write-through · tool_results save
10 Telemetry
metrics_hourly · error_issues (on failure)
11 mvTrackedFetch
activity_log row inside ctx.waitUntil · return the response1. The edge and _routes.json#
_routes.json declares include: ["/*"] and excludes 15 paths: /free/*, /blog.html, the favicons, /apple-touch-icon.png, /og-image.png, /logo-dark.png, /logo-light.png, the four icon files and /manifest.json. Those are served straight from the static asset set and never reach the worker, which is cheaper and skips tracking entirely.
Note: the worker still contains handlers for /blog.html, /logo-dark.png and /logo-light.png. Those handlers are unreachable on any deployment that honours _routes.json.
_headers applies Cache-Control: no-cache, no-store, must-revalidate to /login.html, /login, /dashboard.html, /dashboard, /sw.js, /index.html and /.
2 and 3. The tracking wrapper#
mvTrackedFetch runs before the router on every request. It looks up a route descriptor; if there is none it delegates immediately, which is the path taken by static assets, reads and pollers. Otherwise it buffers the JSON body when content-type is application/json and the length is at or below MV_ACT_MAX_BODY (24,000 bytes), rebuilds an identical Request, and starts a timer. Multipart and streaming bodies pass through unbuffered.
4. The router#
mvRouteRequest is a flat ordered chain. Two blanket checks sit inside it:
- OPTIONS is answered immediately for every path with
Allow-Origin: *,Allow-Methods: GET, POST, OPTIONS,Allow-Headers: Content-Type, Authorization. /api/dbgdfsand/api/diag/*require?key=<MV_INTERNAL_SECRET>and return403 {"ok":false,"error":"forbidden"}withCache-Control: no-storeotherwise. This gate fails closed when the secret is unset.
The full dispatch order is in The worker (_worker.js).
5. The shared-cache wrapper#
/api/premium-ai is not routed to its handler directly. It goes through mvSaveAroundPremiumAI, which:
- Clones and parses the body. An unparseable body passes straight through to the handler.
- Looks the request up in
mv_seo_cacheif, and only if, three things hold: the tool appears inMV_CACHE_TTL_MS, the body sets neitherforceFreshnor_mvForceFresh, andbody.queryis truthy. - The cache key is a revision, then
|, thenmvToolParamsKey(body). The revision isMV_CACHE_KEY_REV[type], orv1for a tool it does not list. It lets a reader fix retire answers the broken reader stored:keyword_overviewisv2, because itsv1entries held rate-limited Google Ads calls stored as 0 searches. A Keyword Overview result markedheadlinePartialis never written to this cache.mvToolParamsKey(body)istype|query|country|device|range|minVolume|intent, lowercased, trimmed and capped at 300 characters. The key contains no user identity, which is what makes the cache cross-customer. - Expiry is checked at read time only:
expires_at > Date.now().
On a hit, in this exact order: resolve the cost, run enforceAiQuota, run incrementUsage, bump the row's hits, record cache_hit metrics, and return the stored body with X-MV-Cache: hit, X-MV-Cache-Age-Days: <n> and Access-Control-Expose-Headers: X-MV-Cache, X-MV-Cache-Age-Days, X-MV-Saved-Id.
Important: a cache hit still charges the customer their full credit. The in-code rationale is that they ran a report; only the provider bill is saved.
On a miss, the handler runs and the response text is written through with mvSeoCachePut, but only if the payload is real: if (!parsed || !parsed.data || parsed.error) return res;.
Components#
The gate chain, in order#
Everything below happens inside handlePremiumAI unless noted.
1. The dedicated growth_actions path. type === 'growth_actions' diverts to handleGrowthActions, which charges nothing at all when it finds no saved source data and returns 200 {needsData:true, ...}.
2. The per-tool kill switch. platform_config key tool_off:<type> set to '1' returns 503 {"error":"This tool is temporarily unavailable. Please try again shortly."}.
3. The recommendation gate. Only for types in AI_RECO_TYPES = { result_guide: 1 }, that is every "Get Recommendations" button. enforceAiRecoQuota checks a minimum plan (default pro, overridable with the ai_reco_min_plan config key) and a per-plan monthly allowance (AI_RECO_QUOTAS_DEFAULT = { starter: 30, pro: 100, agency: 400, enterprise: 2000, unlimited: Infinity }, overridable per plan with ai_reco_quota:<plan>). Metering runs before the upstream call so concurrent requests cannot all pass.
4. The quota gate. enforceAiQuota(env, user_email, { cost }), in this order:
| # | Condition | Result |
|---|---|---|
| 1 | No user_email and cost > 0 | 401 {"error":"Please sign in to run this.","code":"auth_required"} |
| 2 | No user_email and cost 0 | Allowed. The free technical tools stay public |
| 3 | Email in DEV_UNLIMITED_EMAILS | Allowed with no database work at all |
| 4 | platform_config key suspended:<email> is '1' | 403 {"error":"This account is suspended. Please contact support.","code":"account_suspended"} |
| 5 | MONITOR_DB unbound | Allowed. See Failure modes |
| 6 | Plan is free and cost > 0 | 403 code:"upgrade_required", required_plan:"pro" |
| 7 | cost < 3 (PREMIUM_COST_THRESHOLD) | Never quota-blocked. The hourly limiter runs, then the hourly counter increments, then allowed |
| 8 | used + cost > quota | 429 code:"quota_exceeded" with plan, used, quota, cost, reset_at, reset_date, days_until_reset, upgrade_url |
| 9 | Any other exception | 503 {"error":"Usage check temporarily unavailable, please retry.","code":"quota_check_failed"} |
used is ai_runs + tool_runs for the current UTC month (YYYY-MM), minus any USAGE_RESET_BASELINE forgiveness for that account and month.
The hourly limiter is LIGHT_RATE_LIMIT_PER_HOUR = 100 per user per UTC hour, bucketed YYYY-MM-DD-HH in usage_hourly. Over the limit returns 429 with code: "hourly_rate_limit", limit, used and reset_in_minutes. Anonymous /api/tools callers and every /api/translate caller are bucketed by 'ip:' + CF-Connecting-IP instead of by email.
5. Metering. incrementUsage(db, email, 'ai', cost, type) writes usage_counters (the monthly meter) and usage_by_tool (the per-tool breakdown, described in code as best-effort).
7. The provider calls#
DataForSEO calls go through callDataForSEOCached(endpoint, body, env, ttlSec, tool), whose key is endpoint + ':' + JSON.stringify(body). That key also contains no user identity, so this layer is cross-customer too. The TTL is resolved as platform_config['cache_days:<tool>'], then platform_config['cache_days_default'], then the caller's literal, then 86,400 seconds. If D1 is unavailable the helper catches and falls through to an uncached callDataForSEO, so a database problem degrades freshness rather than breaking the call.
The LLM call uses mvOpenAIChatURL(env), which is env.OPENAI_BASE_URL or the default Cloudflare AI Gateway URL, plus /chat/completions. The default model is gpt-4o; article_writer uses gpt-4o-mini to fit the wall-time budget. max_tokens defaults to 4000 with per-type overrides, temperature is 0.7 and the response format is json_object.
Full cache detail, including the per-tool TTL map and the admin override path, is in Caching architecture.
8. Post-processing#
- The real-data honesty check. 30 types are in
REAL_DATA_TYPES. When one of them has no real provider data, the response is200withnoDataAvailable: trueand a message that says so explicitly rather than falling through to AI fabrication. - The year scrub. 19 content types have stale year references rewritten.
- Data-source stamps.
_dataSourceis set todataforseoorai-generated, with a_dataSourceVerifiedboolean. - Friendly error rewriting.
mvFriendlyAIErrortranslates five upstream error classes into customer-readable text, including the region, rate-limit, quota-exhausted and bad-key cases.
9 and 10. Persistence and telemetry#
| Write | Table | Notes |
|---|---|---|
| Shared cache write-through | mv_seo_cache | Only for real payloads |
| Saved result | tool_results | Payloads over 400,000 bytes are silently not saved. Returned as X-MV-Saved-Id |
| Run metrics | metrics_hourly | Scopes tool:<type>, all and provider:<vendor>. Columns include runs, errors, timeouts, quota_hits, dur_sum, cache_hit, cache_miss, cost_micros, tokens_in, tokens_out |
| Grouped error | error_issues | Only on a non-ok response. Fingerprint is a hash of the tool plus a normalised message |
| Activity row | activity_log | Written by the tracking wrapper inside ctx.waitUntil |
Provider cost is real for DataForSEO (read from the provider's own cost field) and estimated for LLMs from a price table in USD per million tokens. Unknown models record tokens with a zero-dollar estimate.
Data flow#
A concrete Domain Overview run by a Pro account, cold cache:
POST /api/premium-aiwith{type:"domain_overview", query:"nike.com", user_email:"..."}. The dashboard's patchedfetchalso attachesAuthorization: Bearer <jwt>, which this handler does not read.- Tracking wrapper buffers the body, starts the timer.
- Router matches, calls
mvSaveAroundPremiumAI. MV_CACHE_TTL_MS.domain_overviewis 10 days,queryis present, no force flag. Cache lookup misses.- Kill switch clear. Not a recommendation type.
getToolCost('domain_overview')is 6.enforceAiQuotafinds the plan ispro(quota 500),used + 6 <= 500, returnsnull.incrementUsageadds 6 tousage_counters.ai_runsand ausage_by_toolrow.- Three DataForSEO endpoints are called through
callDataForSEOCachedwith TTL literals 3600, 86400 and 604800 seconds. The LLM call composes the report. - The body is written to
mv_seo_cachewithexpires_at = now + 10 days, and saved totool_results. metrics_hourlygainsruns +1,cache_miss +1,dur_sum += durationontool:domain_overviewandall, plus aprovider:dataforseorow carrying the real cost.- The response returns with
X-MV-Saved-Id. The activity row is written in the background.
The next customer who runs the same query within 10 days takes the cache-hit path: gate, charge 6 credits, bump hits, return in a few milliseconds with X-MV-Cache: hit. No provider is called.
Failure modes#
| Stage | Failure | Behavior |
|---|---|---|
| Route inclusion | Path is in the _routes.json exclude list | The worker never runs. Its handler for that path is dead code |
| Tracking | Any error in the recording block | Swallowed. Tracking must never break a response |
| Tracking | The router throws | Rethrown. Cloudflare's own error page is served, not a JSON body |
| Config read | D1 unreachable | mvConfigAll fails open and returns an empty map, so suspensions and cache overrides momentarily disappear |
| Quota gate | MONITOR_DB is unbound | Fails open. monRequireDB throws and the gate returns null, allowing the request |
| Quota gate | Any other exception | Fails closed with 503 quota_check_failed |
| Hourly limiter | Any database error | Fails open, returns null |
| Plan gate | Any database error | Fails closed with 503 plan_check_failed |
| DataForSEO cache | D1 unavailable | Falls through to an uncached provider call |
| Provider | Credentials missing | Real-data tools return the honest empty result; some routes 503 |
| Provider | Upstream error | 500 with a rewritten friendly message, plus an error_issues row |
| Persistence | Result over 400 KB | Silently not saved to the Library. The response is unaffected |
| Telemetry | Any metric or error write fails | Swallowed. Observability must never break the product |
The open-versus-closed split is deliberate but not uniform, and the two behaviors inside enforceAiQuota are the subtlest part of the whole pipeline. Authentication and authorization flow lays out every gate side by side.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.