2024-12-09 Implementation Plan: Proxy Frontend API Requests Through Nuxt¶
Author: @obulat
Reviewers¶
Project Links¶
This project does not have a project proposal because the scope and rationale of the project are clear, as defined in the project thread.
Overview¶
Currently, client requests[1] from the frontend to the API are sent without authentication. As a result, strict API rate limiting on unauthenticated requests would inadvertently throttle legitimate traffic from openverse.org users. By proxying all client requests[1] through the Nuxt server, we can authenticate them and thus distinguish them from the anonymous traffic in the API.
This implementation plan outlines how we will route all frontend traffic to the API through Nuxt server routes, which authenticates these requests at the API level, and limit these routes in the Nitro server to protect them from excessive automated traffic that is against Openverse Terms of Service.
This plan does not lower the unauthenticated rate limits in the API. It lays the groundwork, and the follow-up work describes what remains. This keeps the API stable and fair for everyone, and frontend users shouldn’t notice a difference.
Expected Outcomes¶
Distinguishable frontend requests in the Django API: For the Django API, the frontend requests are authenticated and no longer constrained by unauthenticated rate limits.
The proxy is not a way around the API rate limits: The Nitro server limits requests per visitor and per IP address, and the API limits the frontend’s total usage. Legitimate users, including those on shared IP addresses at schools and libraries, keep reliable access.
Request Flow¶
The openverse.org DNS record is not proxied by Cloudflare, so browsers connect
directly to the Nuxt server. The API hosts are proxied.
When a user loads a page, Nuxt renders it on the server (SSR) and sets a session
cookie if the user doesn’t have one. During SSR, the stores call the /api
routes in-process, so these calls don’t make a network request. After the first
load, the stores call the same routes from the browser, and the session cookie
is sent with them. In both cases, the Nitro route adds the frontend credentials
and sends the request to the Django API.
sequenceDiagram
participant U as Browser
participant N as Nuxt (Nitro)
participant A as API
U ->> N: Page load or /api request
alt under the rate limits
N ->> A: Request authenticated with frontend credentials
A ->> N: Response
N ->> U: Response
else over a rate limit
N ->> U: 429 with a message
end
Rate limiting¶
The proxy routes are public and use the frontend’s credentials, so without limits of their own, a script blocked by the anonymous API limits could use them instead.
Cloudflare can’t limit these routes while the openverse.org record isn’t
proxied. We will limit them in Nitro first, and put Cloudflare in front of
openverse.org if usage stays too high. See
the follow-up work.
Counters¶
The session cookie lets Nitro separate the people who share an IP address, such as in a library. Every request that counts also counts toward the per-IP ceiling.
Request |
Counted by |
Limit |
|---|---|---|
|
Session |
Normal use by one person |
|
IP |
Low |
Page load that calls the API, with a session |
Session |
Normal use by one person |
All of the above |
IP |
Very high, the per-IP ceiling |
A page load without a session counts only toward the per-IP ceiling, because every new visitor arrives this way and many can share one IP address.
SSR calls to the
/apiroutes are not counted separately, because their page load is already counted. SSR search pages call the API, so if page loads weren’t counted, a script could request/search?q=catto avoid the limits.Browsers that block cookies share the low limit for their IP address, so some of these users can receive a 429.
The counters are in memory and are per Nuxt task, so the limits are approximate. This is enough for generous limits.
Backstop in the API¶
The frontend’s API application gets a high but finite rate limit. If a script gets past the Nitro limits, for example by using many IP addresses, this limit protects the API. openverse.org search can degrade for everyone when it’s reached, so we need an alert before that point.
Impact on the Nuxt Server¶
Load on Nuxt CPU/memory Proxying the requests by the Nuxt server will increase the server load. We need to ensure that the server can handle that load by monitoring and adjusting the values if necessary. The server proxy routes should be written in such a way that they are not consuming CPU resources heavily.
Step-by-step plan¶
Step Details¶
Add the frontend feature flag¶
Add proxy_requests feature flag to frontend/feat/feature-flags.json:
{
"proxy_requests": {
"description": "Proxy frontend requests through Nuxt server",
"status": {
"staging": "switchable",
"production": "disabled"
},
"defaultState": "off",
"storage": "cookie"
}
}
Give the frontend’s API application its own rate limit¶
All openverse.org traffic will use one API client_id, so it will share one set
of API rate limits. The existing models don’t fit: standard allows 100
requests per minute, enhanced allows 200, and exempt has no limit and so no
backstop.
Add a new
rate_limit_modeltoThrottledApplication, with burst and sustained throttles sized from the frontend’s current API request rate.In the Django admin (maintainers only), confirm that the frontend’s application is
verified, uses the new model, and has noprivileges, because the proxy doesn’t need the larger page size and result depth that they unlock.Add an alert for when usage gets close to the limit.
Add a route from Nitro to the API that Cloudflare can’t challenge¶
The API hosts are proxied by Cloudflare, and the frontend reads the API address
from public.apiUrl, so it may call the API through Cloudflare from a small set
of IP addresses. The rate limiting rules on the API hosts could then count all
users’ requests against those addresses. The rules for repeated 400, 401, and
403 responses and for /v1/auth_tokens/token/ are the most likely to fire.
Nitro can’t solve a managed challenge, so this would break search for all users.
Confirm the production value of
NUXT_PUBLIC_API_URL.Add a private
apiUrltoruntimeConfigfor Nitro, pointing at an internal API address. If there’s no internal route, exclude Nuxt’s outbound IP addresses from these rules usingip.src. This requires stable outbound addresses.
Add session to the init plugin¶
When the user sends the first request to any Openverse frontend URL, the Nuxt
server initializes their session, which makes their later requests identifiable
as one user’s requests, even on a shared IP address. Add this to the
frontend/src/plugins/02.init.server.ts plugin, the entry point for SSR
requests.
Add a session using the unjs/h3
useSession hook. The session
should:
use the
HMAC_SIGNING_SECRETenv variable to sign the session, so that a script can’t make up session values.be named
ovSession, and the session cookie should beHttpOnly,Secure, andSameSite=Lax, and have a short maximum age. WithSameSite=Strict, users arriving from an external link wouldn’t send the cookie and would get a new session.
Add the server plugin to initialize the Openverse API client and add it to the event context¶
Create a nitro plugin using
defineNitroPlugin
in server/plugins/proxy.ts. This plugin should:
initialize the
OpenverseApiClient. If the API secrets are set, use authenticated client. Otherwise, in development environment, use unauthenticated client, and in staging/production, throw an error, as the API requests from the frontend should always be authenticated. To access the secrets, the plugin should use theprocess.env.[VARIABLE_NAME], notruntimeConfig.VARIABLE_NAMEsince theruntimeConfigis not available in the plugin body.add a middleware using
defineEventHandlerthat adds theOpenverseApiClientto the event context asevent.context.apiClient.
Set up Nuxt Server proxy routes¶
These routes are public and use the frontend’s credentials, so they must not
give visitors more access than a direct anonymous API request. Authenticated
requests can use a page_size of up to 50, while anonymous requests can use up
to 20. Passing parameters straight through would give every visitor the higher
limit.
Create the Nuxt server routes under
/api/:/api/search/[type]- for search requests/api/[type]/[id]- for single result requests/api/[type]/related/[id]- for related media requests
Create a
proxyApiRequestfunction that accepts the H3eventobject and forwards the request to the API.validateRequestfunction should:ensure that the media type is one of the supported media types, and that the media id is a valid UUID, so that the routes can’t reach any other API endpoints.
keep only the query parameters that the frontend sends, using an allowlist, and drop all others.
reject a
page_sizeabove the anonymous maximum, or drop the parameter if the frontend doesn’t need it.
generateApiUrlfunction should generate the API request URL based on the request details (for example,/api/search/image/?q=catshould be converted to<apiUrl>/v1/images/?q=cat).send the request to the API using the Openverse API client from
event.context.apiClient. Do not forward the browser’s cookies to the API.handle errors, extract data necessary for the search time Plausible event, and return the appropriate response to the client.
Use server routes in the frontend when the feature flag is on¶
Create two fetch functions in the stores: one that uses the current direct API
requests with api-service, and the other that sends the request to the Nuxt
server route. The fetch function to use should be selected based on the feature
flag status.
Use useRequestFetch for the server route requests. During SSR it calls the
Nitro route in-process, and in the browser it behaves like $fetch, so SSR and
client requests share one code path.
The change will need to be added to the related, single-media and media
stores. Here’s sample implementation in the media store,
frontend/src/stores/media/index.ts:
if (featureFlagStore.isOn("proxy_requests")) {
const requestFetch = useRequestFetch()
const result = await getProxiedResponse(requestFetch, mediaType, queryParams)
} else {
const client = useApiClient()
const result = await getApiClientResponse(client, mediaType, queryParams)
if (result.error) {
// handle the error
return null
}
// handle the response
}
const getProxiedResponse = async (
requestFetch: typeof $fetch,
mediaType: string,
queryParams: Record<string, string>
) => {
try {
const { eventPayload, data } = await requestFetch(
`/api/search/${mediaType}/?${queryParams}`
)
return { eventPayload, data }
} catch (error) {
if (isRateLimited(error)) {
// show the rate limit message, using the Retry-After header
}
// return the $fetch error
}
}
const getApiClientResponse = async (
client: ApiClient,
mediaType: string,
queryParams: Record<string, string>
) => {
try {
const { eventPayload, data } = await client.search(mediaType, queryParams)
return { eventPayload, data }
} catch (error) {
// return the error
}
}
The new functions should not call any composables that require the Nuxt context
(e.g., useNuxtApp) because [nuxt] is not available in nested functions. This
is why useRequestFetch is called before them.
isRateLimited checks for a 429 response. The frontend shows a message that
explains the limit and when to try again, instead of the regular error page.
Add the rate limiting to Nitro in log-only mode¶
Add the counters to the /api routes and to the pages that call
the API, using a server middleware. Use a runtimeConfig setting to switch
between logging and enforcing, and start with logging.
Read the client IP from the
X-Forwarded-Forentry added by our load balancer. Earlier entries come from the client and can be faked.Identify in-process SSR calls in a way that a client can’t fake.
Do not log session values or IP addresses. Log which counter a request hit and whether it would have been limited.
When enforcing, return a 429 with a
Retry-Afterheader. The frontend must show a clear message for this response, including on SSR pages.
Switch the feature flag on in production¶
Set the proxy_requests feature flag to enabled in the production
environment. This will start routing all frontend requests through the Nuxt
server.
Monitor usage and enforce the limits¶
Keep the limits in log-only mode long enough to see normal traffic from shared IP addresses, ideally including school hours. Then set the numbers and switch to enforcing. The following data need to be monitored:
The number of requests that would be limited, by counter. Look for IP addresses with many sessions, which are likely to be institutions.
The 429 rate after enforcing, and user reports of being blocked.
The frontend application’s usage against its API limit.
Nuxt CPU and memory usage. In case the server is under heavy load, we might need to adjust the rate limits and/or frontend task values.
API logs for unauthenticated search, single result, and related media requests from openverse.org. These must drop to about zero before the anonymous limits are tightened.
Remove the feature flag and the direct API client¶
Once the flag has been stable in production, remove the proxy_requests flag,
the direct fetch functions in the stores, frontend/src/data/api-service.ts,
and the 01.api-token.server.ts plugin. The API credentials then exist only in
the Nitro server.
Follow-up work¶
This project does not change the anonymous limits. These changes come after the flag is on:
Search, single result, and related media: Lower
THROTTLE_ANON_BURSTandTHROTTLE_ANON_SUSTAINED, after confirming the current production values. Audio waveforms are covered because they come from thepeaksparameter on these requests.Thumbnails: Browsers load thumbnails directly from the API, so they remain anonymous and are counted by IP. Cloudflare serves them, but a request that misses its cache still reaches Django. The default
THROTTLE_ANON_THUMBSis 150 requests per minute, and 30 students each loading a page of 20 results make about 600 requests. Check the cache hit rate and the thumbnail 429 rate, and keep this limit generous.Openverse referrer throttles: The
ov_referrerthrottles trust areferrer: openverse.orgrequest header, which the frontend doesn’t send. If something upstream adds this header, anyone can spoof it to avoid the anonymous limits. Remove these throttles when tightening the limits.Cloudflare in front of
openverse.org: If usage stays too high, proxy this record and add rate limiting rules that count by IP with NAT support, with a per-IP ceiling. Requests are then rejected before they reach Nuxt, and a blocked user can solve a managed challenge. This requires restricting the origin to Cloudflare’s IP ranges, reading the client IP from Cloudflare, and handling challenges onfetchrequests. The zone has 2 free rate limiting rules, on the Business plan.
Rejected Alternatives¶
Proxying thumbnail requests The thumbnail requests from the frontend will remain anonymous on the API level. Cloudflare serves them, and proxying them would add load to the Nuxt server.
Tightening the anonymous limits without a proxy This is the smallest change, but it throttles the shared IP addresses that this project is meant to protect.
Limiting only by IP in Nitro This would recreate the shared IP problem at the proxy. Sessions let a library’s users have separate counters.
Cloudflare in front of
openverse.orgas the first step This would reject abusive requests earlier and give blocked users a challenge they can solve, but it moves all production traffic. We will do it if the Nitro limits aren’t enough. See the follow-up work.
Dependencies¶
Feature Flags:
proxy_requests
Infrastructure: An internal route from the frontend to the API, a new API rate limit model, the
HMAC_SIGNING_SECRETsecret, and monitoring (Sentry).Tools & Packages:
ofetch: This would be a great occasion to move away fromaxios.ofetchis used internally by Nuxt, so it’s a good choice for the server proxying. The store requests can be made with Nuxt helper wrapper overofetch,$fetch[2].h3for server route handling and session management.
Accessibility and Privacy¶
Accessibility: The 429 message must be translated and available to screen readers.
Privacy: The session cookie is anonymous and short-lived, and no personal data is stored. The in-memory counters use IP addresses only for as long as a counting window lasts.
Rollback Strategy¶
Set the
proxy_requestsfeature flag tooff. The frontend then calls the API directly again.To keep the proxy on but stop blocking users, switch the Nitro limits back to log-only mode.