502 Proxy Error on Mobile (iPhone) for Gemini API Call with ReadableStream

I tried to fix this issue, but ultimately found it too troublesome. If you’re determined to fix it manually, the following facts might persuade you to give up sooner:

  1. When you deploy a project from AI Studio, static files like HTML and JS are bundled and uploaded to a Cloud Storage bucket, as shown in the image. When users access your service, your homepage is loaded from this bucket.

  2. AI Studio automatically creates a serviceworker.js. This script’s purpose is to intercept requests to the Google GenAI API, rerouting them to your own service’s host to protect your API key. This serviceworker.js is the root of all evil; it causes errors on iOS or Safari because of how it handles streamable uploads. You need to replace this file.

  3. You can manually write a new service worker file and upload it to the aforementioned storage bucket. Then, in your index.html or index.jsx, you need to install this new service worker to replace the problematic one. However, you must time the installation correctly (e.g., 3 seconds after the page has finished loading) and add specific configurations to ensure the replacement takes effect.

Every time you redeploy the project, you have to repeat this entire process. Sad…

My advice: drop it. It’s Google’s internal mess. Fixing it yourself is a fool’s errand. Just refactor for production, move to Cloudflare, and call it a day.

1 Like

Yeah! It’s kind of funny because the exact same Code AI Studio provide works with Netlify .

Thanks Bob! It’s so annoying … I don’t know why it needs to be like this…

Had the same problem, found this post. Thank you for digging and finding the root cause man!

I simply created the service-workers.js in the root folder in AI Studio and copied the content from the original file in Cloud Run’s source code, explained the issue and error to the AI and asked it to fix the code.

It did. Then I copied the fixed code, edited the source in Cloud Run, pasted the fixed code there and redeployed - hurray, it works.
But every time I re-deploy via AI Studio I need to take this step.

1 Like

Thanks @LegoJesus can you share the updated service-workers.js file here?

//THIS FILE NEEDS TO OVERWRITE THE EXISTING ONE IN CLOUD RUN'S SOURCE CODE IN ORDER FOR iOS DEVICES TO WORK
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
// service-workers.js

// This service worker intercepts requests to the Google Generative AI API 
// and proxies them through the application's backend to avoid CORS issues
// and keep the API key secure. It includes a fix for browsers that do not
// support ReadableStream in request bodies (e.g., Safari on iOS).

const TARGET_URL_PREFIX = 'https://generativelanguage.googleapis.com';

// The install event is fired when the service worker is first installed.
self.addEventListener('install', (event) => {
  console.log('Service Worker: Installing...');
  // self.skipWaiting() forces the waiting service worker to become the
  // active service worker.
  event.waitUntil(self.skipWaiting());
});

// The activate event is fired when the service worker is activated.
self.addEventListener('activate', (event) => {
  console.log('Service Worker: Activating...');
  // self.clients.claim() allows an active service worker to take control
  // of all clients (pages) that are in its scope.
  event.waitUntil(self.clients.claim());
});

// The fetch event is fired for every network request made by the page.
self.addEventListener('fetch', (event) => {
  try {
    const requestUrl = event.request.url;

    // Check if the request is for the target API.
    if (requestUrl.startsWith(TARGET_URL_PREFIX)) {
      // We use an async IIFE passed to event.respondWith to handle the
      // asynchronous nature of fetching and body buffering.
      event.respondWith((async () => {
        try {
          console.log(`Service Worker: Intercepting request to ${requestUrl}`);
          
          // Construct the proxy URL.
          const remainingPathAndQuery = requestUrl.substring(TARGET_URL_PREFIX.length);
          const proxyUrl = `${self.location.origin}/api-proxy${remainingPathAndQuery}`;
          console.log(`Service Worker: Proxying to ${proxyUrl}`);

          const newHeaders = new Headers();
          // Copy essential headers from the original request to the new one.
          const headersToCopy = [
            'Content-Type',
            'Accept',
            'Access-Control-Request-Method',
            'Access-Control-Request-Headers',
          ];

          for (const headerName of headersToCopy) {
            if (event.request.headers.has(headerName)) {
              newHeaders.set(headerName, event.request.headers.get(headerName));
            }
          }
          
          if (event.request.method === 'POST' && !newHeaders.has('Content-Type')) {
              console.warn("Service Worker: POST request was missing Content-Type. Defaulting to application/json.");
              newHeaders.set('Content-Type', 'application/json');
          }

          // WORKAROUND for Safari/iOS: Buffer the request body.
          // Some browsers do not support ReadableStream as a request body.
          // To fix this, we read the stream into an ArrayBuffer before fetching.
          let body = null;
          if (event.request.method !== 'GET' && event.request.method !== 'HEAD' && event.request.body) {
              try {
                  // .clone() is essential because the request body can only be read once.
                  body = await event.request.clone().arrayBuffer();
              } catch (e) {
                  console.error("Service Worker: Could not read request body.", e);
              }
          }

          const requestOptions = {
            method: event.request.method,
            headers: newHeaders,
            body: body, // Use the buffered ArrayBuffer.
          };
          
          const proxyRequest = new Request(proxyUrl, requestOptions);
          const response = await fetch(proxyRequest);
          
          console.log(`Service Worker: Successfully proxied request to ${proxyUrl}, Status: ${response.status}`);
          return response;

        } catch (error) {
          console.error(`Service Worker: Error proxying request to ${requestUrl}.`, error);
          // If the proxy fails, return a user-friendly error response.
          return new Response(
            JSON.stringify({
              error: 'Proxying failed',
              details: error.message,
              name: error.name
            }), {
              status: 502, // Bad Gateway
              headers: { 'Content-Type': 'application/json' }
            }
          );
        }
      })());
    } else {
      // For all other requests that don't match the target URL,
      // let them pass through to the network as normal.
      event.respondWith(fetch(event.request));
    }
  } catch (error) {
    // Log more error details for unhandled errors too
    console.error('Service Worker: Unhandled error in fetch event handler. Message:', error.message, 'Name:', error.name, 'Stack:', error.stack);
    event.respondWith(
      new Response(
        JSON.stringify({ error: 'Service worker fetch handler failed', details: error.message, name: error.name }),
        {
          status: 500,
          headers: { 'Content-Type': 'application/json' }
        }
      )
    );
  }
});
3 Likes

Legend, thank you !

Np :slightly_smiling_face:

Thank you! That works! Also seems like if I recreate the folder and file in ai studio, it will be used when re-deploy.

You mean adding the folders structure Server → Public → service-worker.js?

That’s excellent, thanks for pointing it out!

Hi guys, I do not manage to get it working. I pasted @LegoJesus code into the serviceworkers.js and registered it in the app but the issue persists on Apple devices.

Any further ideas how I could solve this?

Hi guys, I tried it and it worked, fixed the 502 error on Android (thanks @LegoJesus) but on IOS, I was getting a different error, API key not valid" error. So when I checked, I realised that the API key is typically passed in a specific HTTP header (e.g., x-goog-api-key or an Authorization header) or as a query parameter. The current service-worker.js code only explicitly copies a few headers (Content-Type, Accept, Access-Control-Request-Method, Access-Control-Request-Headers). So it would appear that the API key is being sent in a header not on this list, so the service worker might be dropping it when it reconstructs the request for your /api-proxy.

So I updated the service-worker.js code in the service-worker-code immersive to include x-goog-api-key in the list of headers to copy.

see the updated code below;

/**

  • @license
  • Copyright 2025 Google LLC
  • SPDX-License-Identifier: Apache-2.0
    */
    // service-workers.js

// This service worker intercepts requests to the Google Generative AI API
// and proxies them through the application’s backend to avoid CORS issues
// and keep the API key secure. It includes a fix for browsers that do not
// support ReadableStream in request bodies (e.g., Safari on iOS).

const TARGET_URL_PREFIX = ‘https://generativelanguage.googleapis.com’;

// The install event is fired when the service worker is first installed.
self.addEventListener(‘install’, (event) => {
console.log(‘Service Worker: Installing…’);
// self.skipWaiting() forces the waiting service worker to become the
// active service worker.
event.waitUntil(self.skipWaiting());
});

// The activate event is fired when the service worker is activated.
self.addEventListener(‘activate’, (event) => {
console.log(‘Service Worker: Activating…’);
// self.clients.claim() allows an active service worker to take control
// of all clients (pages) that are in its scope.
event.waitUntil(self.clients.claim());
});

// The fetch event is fired for every network request made by the page.
self.addEventListener(‘fetch’, (event) => {
try {
const requestUrl = event.request.url;

// Check if the request is for the target API.
if (requestUrl.startsWith(TARGET_URL_PREFIX)) {
  // We use an async IIFE passed to event.respondWith to handle the
  // asynchronous nature of fetching and body buffering.
  event.respondWith((async () => {
    try {
      console.log(`Service Worker: Intercepting request to ${requestUrl}`);

      // Construct the proxy URL.
      const remainingPathAndQuery = requestUrl.substring(TARGET_URL_PREFIX.length);
      const proxyUrl = `${self.location.origin}/api-proxy${remainingPathAndQuery}`;
      console.log(`Service Worker: Proxying to ${proxyUrl}`);

      const newHeaders = new Headers();
      // Copy essential headers from the original request to the new one.
      const headersToCopy = [
        'Content-Type',
        'Accept',
        'Access-Control-Request-Method',
        'Access-Control-Request-Headers',
        // ADDED: Explicitly copy the Google API Key header
        'x-goog-api-key', 
        // If your API key is in an Authorization header (e.g., Bearer token), uncomment below:
        // 'Authorization', 
      ];

      for (const headerName of headersToCopy) {
        if (event.request.headers.has(headerName)) {
          newHeaders.set(headerName, event.request.headers.get(headerName));
        }
      }

      if (event.request.method === 'POST' && !newHeaders.has('Content-Type')) {
          console.warn("Service Worker: POST request was missing Content-Type. Defaulting to application/json.");
          newHeaders.set('Content-Type', 'application/json');
      }

      // WORKAROUND for Safari/iOS: Buffer the request body.
      // Some browsers do not support ReadableStream as a request body.
      // To fix this, we read the stream into an ArrayBuffer before fetching.
      let body = null;
      if (event.request.method !== 'GET' && event.request.method !== 'HEAD' && event.request.body) {
          try {
              // .clone() is essential because the request body can only be read once.
              body = await event.request.clone().arrayBuffer();
          } catch (e) {
              console.error("Service Worker: Could not read request body.", e);
          }
      }

      const requestOptions = {
        method: event.request.method,
        headers: newHeaders,
        body: body, // Use the buffered ArrayBuffer.
      };

      const proxyRequest = new Request(proxyUrl, requestOptions);
      const response = await fetch(proxyRequest);

      console.log(`Service Worker: Successfully proxied request to ${proxyUrl}, Status: ${response.status}`);
      return response;

    } catch (error) {
      console.error(`Service Worker: Error proxying request to ${requestUrl}.`, error);
      // If the proxy fails, return a user-friendly error response.
      return new Response(
        JSON.stringify({
          error: 'Proxying failed',
          details: error.message,
          name: error.name
        }), {
          status: 502, // Bad Gateway
          headers: { 'Content-Type': 'application/json' }
        }
      );
    }
  })());
} else {
  // For all other requests that don't match the target URL,
  // let them pass through to the network as normal.
  event.respondWith(fetch(event.request));
}

} catch (error) {
// Log more error details for unhandled errors too
console.error(‘Service Worker: Unhandled error in fetch event handler. Message:’, error.message, ‘Name:’, error.name, ‘Stack:’, error.stack);
event.respondWith(
new Response(
JSON.stringify({ error: ‘Service worker fetch handler failed’, details: error.message, name: error.name }),
{
status: 500,
headers: { ‘Content-Type’: ‘application/json’ }
}
)
);
}
});

Hello everyone,

Thank you for the tremendous effort. To help move this forward, I believe it would be more effective to contact the appropriate team internally.

Could someone please assist in forwarding this post to the relevant contacts at Google?

Thank you for your help.

1 Like

Hey folks,

This issue has been resolved in the latest version of us-docker.pkg.dev/cloudrun/container/aistudio/applet-proxy:latest. Re-deploying from AI Studio will pick up the latest container version and fix issues on safari/iOS.

1 Like