Google Merchant Center Image Crawl Errors: Diagnose 403, 404 and HTML

Google Merchant Center Image Crawl Errors: Diagnose 403, 404 and HTML

Rewarx HTTP diagnostic lab · November 4, 2026 · Local fixtures

An image URL can return HTTP 200 and still deliver a sign-in page, CDN challenge or HTML error. This guide uses a controlled local HTTP server to demonstrate how to record status, headers, redirects and decodable image bytes. Twelve checks passed on November 4, 2026. They describe this diagnostic client’s observations, not Googlebot’s access or Merchant Center approval.

Start with the actual symptom

Observed responseNext investigation
200 with HTMLCheck whether the URL is an asset link, login page, application route or challenge.
403Correlate the request with access and security logs. Authentication, hotlink rules, geography and WAF policy can differ.
404Check the exact object path, encoding and whether an old image URL was removed.
429Read Retry-After and review allowed request rates. The script does not retry automatically.
5xxCheck origin health, CDN errors and the timestamp of the failing request.
Redirect chain or loopFind the final image URL; investigate stale redirects, cookies or application middleware.
TimeoutCheck latency, response size, upstream availability and your client timeout separately.

Processing time is not the same as a crawl error

Google’s image-processing guide says normal processing typically takes up to three days and can take up to thirty. It advises investigating server, firewall or header issues if processing exceeds seven days. New items remain under review; an existing approved item can temporarily show its previous image during the new image’s processing window. Thirty days is an outer window in that guidance, not a mandatory wait before diagnosis.

For unchanged URLs, the guide warns detection can take up to three weeks. It discusses a new filename/URL for a genuinely updated asset, typically crawled within three days, while processing can still remain pending. Its thirty-day failure path and the separate product-crawl article’s up-to-48-hour return-to-listings note describe different stages. None is a guaranteed completion time for a particular item. Read the current official guidance before changing a production feed.

Actual local HTTP results

FixtureActual status pathDiagnostic classification
/image200IMAGE_OK
/html200HTML_INSTEAD_OF_IMAGE
/403403HTTP_403
/404404HTTP_404
/429429HTTP_429
/503503HTTP_503
/redirect-one302 → 301 → 200IMAGE_OK
/timeoutNo response before timeoutTIMEOUT
/loop302REDIRECT_LOOP
/escape302FIXTURE_ORIGIN_ESCAPE_BLOCKED
public-mode-private-targetBlocked before public requestURL_NOT_PUBLIC_HTTPS
public-mode-private-targetBlocked before public requestNONPUBLIC_ADDRESS

These were actual requests to a Python ThreadingHTTPServer bound to a randomly assigned loopback port. The timeout route deliberately delayed its response; the redirection route returned 302, then 301, then a real PNG. The loop, origin-escape and public/private checks are deliberately constructed cases. They are not production outage statistics.

The 429 fixture returned Retry-After: 120. The diagnostic recorded that header without issuing another request. IMAGE_OK means this client decoded an image with an image content type; it does not certify visual quality, policy compliance or access from Google’s network.

Run the read-only diagnostic

Use Python 3.9+ and Pillow. Save merchant_image_preflight.py from the dimension-audit guide beside the following diagnostic. It reuses that program’s public-address validation and image decoder; it does not create a separate scraping framework.

python -m pip install Pillow
python image_http_diagnostic.py https://your-public-host.example/product.png --output diagnostic.json
Complete diagnostic source
"""Read-only HTTP image diagnostic; not a Google crawler/approval test.
Save merchant_image_preflight.py beside this file (published with the size audit).
python image_http_diagnostic.py https://public.example/image.png --output report.json
Loopback HTTP is allowed only with the dedicated --fixture-base option.
"""
import argparse,json,http.client,socket,ssl,time,threading
from urllib.parse import urlsplit,urljoin,urlunsplit
from pathlib import Path
from merchant_image_preflight import public_target,inspect_bytes,AuditError,MAX_BYTES

UA='RewarxReadOnlyImageDiagnostic/1.0'

def display_url(url):
    u=urlsplit(url)
    return urlunsplit((u.scheme,u.netloc,u.path,'REDACTED' if u.query else '', ''))

def connect(url,fixture_base,timeout):
    u=urlsplit(url)
    if fixture_base:
        base=urlsplit(fixture_base)
        if base.scheme!='http' or base.hostname!='127.0.0.1' or not base.port or base.username or base.password or base.path not in ('','/') or base.query or base.fragment:
            raise AuditError('INVALID_FIXTURE_BASE')
        if u.scheme!='http' or u.hostname!='127.0.0.1' or u.port!=base.port or u.username or u.password:
            raise AuditError('FIXTURE_ORIGIN_ESCAPE_BLOCKED')
        conn=http.client.HTTPConnection('127.0.0.1',base.port,timeout=timeout)
        conn.connect()
    else:
        u,ip=public_target(url)
        conn=http.client.HTTPSConnection(u.hostname,443,timeout=timeout)
        raw=socket.create_connection((ip,443),timeout=timeout)
        try:conn.sock=ssl.create_default_context().wrap_socket(raw,server_hostname=u.hostname)
        except Exception:raw.close();raise
    return conn,u

def diagnose(url,fixture_base=None,timeout=4,max_redirects=3):
    result={'input':display_url(url),'mode':'local_fixture' if fixture_base else 'public_https','user_agent':UA,'hops':[],'classification':'','google_fetch_verified':False}
    seen=set();started=time.monotonic()
    try:
        for hop in range(max_redirects+1):
            if time.monotonic()-started>20:raise AuditError('TOTAL_TIME_LIMIT')
            if url in seen:raise AuditError('REDIRECT_LOOP')
            seen.add(url)
            conn,u=connect(url,fixture_base,timeout)
            bound_socket=conn.sock
            def abort():
                try:bound_socket.shutdown(socket.SHUT_RDWR)
                except OSError:pass
            timer=threading.Timer(max(.01,20-(time.monotonic()-started)),abort);timer.daemon=True;timer.start()
            try:
                path=u.path or '/'
                if u.query:path+='?'+u.query
                conn.request('GET',path,headers={'User-Agent':UA,'Accept':'image/*'})
                resp=conn.getresponse()
                names=['Content-Type','Cache-Control','Last-Modified','ETag','Retry-After','Location','Server']
                headers={n:resp.getheader(n) for n in names if resp.getheader(n) is not None}
                if 'Location' in headers:headers['Location']=display_url(urljoin(url,headers['Location']))
                result['hops'].append({'url':display_url(url),'status':resp.status,'headers':headers})
                if resp.status in (301,302,303,307,308):
                    location=resp.getheader('Location')
                    if not location:raise AuditError('REDIRECT_WITHOUT_LOCATION')
                    if hop==max_redirects:raise AuditError('REDIRECT_LIMIT')
                    # connect() revalidates every destination; no automatic redirect client.
                    url=urljoin(url,location);continue
                if resp.status!=200:
                    result['classification']='HTTP_'+str(resp.status);return result
                size=resp.getheader('Content-Length')
                if size and int(size)>MAX_BYTES:raise AuditError('BYTE_LIMIT')
                data=bytearray()
                while True:
                    if time.monotonic()-started>20:raise AuditError('TOTAL_TIME_LIMIT')
                    chunk=resp.read1(min(65536,MAX_BYTES+1-len(data)))
                    if not chunk:break
                    data.extend(chunk)
                    if len(data)>MAX_BYTES:raise AuditError('BYTE_LIMIT')
                result['bytes_read']=len(data)
                ctype=(resp.getheader('Content-Type') or '').split(';')[0].lower()
                prefix=bytes(data[:256]).lstrip().lower()
                if ctype=='text/html' or prefix.startswith((b'<!doctype html',b'<html')):
                    result['classification']='HTML_INSTEAD_OF_IMAGE';return result
                try:info=inspect_bytes(bytes(data))
                except Exception:
                    result['classification']='NOT_DECODABLE_AS_IMAGE';return result
                result['image']={k:info[k] for k in ['width','height','format','bytes']}
                result['classification']='IMAGE_OK' if ctype.startswith('image/') else 'IMAGE_CONTENT_TYPE_REVIEW'
                return result
            finally:timer.cancel();conn.close()
    except (TimeoutError,socket.timeout):result['classification']='TIMEOUT'
    except AuditError as e:result['classification']=str(e)
    except Exception as e:result['classification']='TOTAL_TIME_LIMIT' if time.monotonic()-started>=20 else 'REQUEST_ERROR_'+type(e).__name__
    return result

def main():
    p=argparse.ArgumentParser();p.add_argument('url');p.add_argument('--output',required=True);p.add_argument('--fixture-base',help='Dedicated local test server origin only, e.g. http://127.0.0.1:12345');a=p.parse_args()
    result=diagnose(a.url,fixture_base=a.fixture_base)
    Path(a.output).write_text(json.dumps(result,indent=2));print(json.dumps(result,indent=2))

if __name__=='__main__':main()

Reproduce all twelve checks

Save the fixture server below as image_http_fixtures.py in the same directory as both scripts. Run it with a new report filename. It starts the loopback server, performs the requests, asserts the expected classifications and shuts the server down.

python image_http_fixtures.py fixture-results.json
Complete fixture server and assertions
"""Actual local HTTP fixtures for image_http_diagnostic.py. Requires Pillow.
python image_http_fixtures.py fixture-results.json
Starts on a random loopback port and shuts down after the test run.
"""
import io,json,sys,threading,time,platform
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from PIL import Image
import PIL
from image_http_diagnostic import diagnose

buf=io.BytesIO();Image.new('RGB',(500,500),(28,110,82)).save(buf,format='PNG');PNG=buf.getvalue()

class Handler(BaseHTTPRequestHandler):
    def log_message(self,*args):pass
    def do_GET(self):
        status=200;ctype='image/png';body=PNG;extra={}
        if self.path=='/html':ctype='text/html';body=b'<!doctype html><html>Sign in or challenge</html>'
        elif self.path in ['/403','/404','/429','/503']:
            status=int(self.path[1:]);body=b'fixture status';ctype='text/plain'
            if status==429:extra['Retry-After']='120'
        elif self.path=='/redirect-one':status=302;extra['Location']='/redirect-two';body=b''
        elif self.path=='/redirect-two':status=301;extra['Location']='/image';body=b''
        elif self.path=='/loop':status=302;extra['Location']='/loop';body=b''
        elif self.path=='/escape':status=302;extra['Location']='http://127.0.0.1:1/private';body=b''
        elif self.path=='/timeout':time.sleep(.5)
        self.send_response(status);self.send_header('Content-Type',ctype);self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','public, max-age=60')
        for k,v in extra.items():self.send_header(k,v)
        self.end_headers()
        try:self.wfile.write(body)
        except (BrokenPipeError,ConnectionResetError):pass

def run():
    server=ThreadingHTTPServer(('127.0.0.1',0),Handler);base='http://127.0.0.1:'+str(server.server_port)
    thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
    expected={'/image':'IMAGE_OK','/html':'HTML_INSTEAD_OF_IMAGE','/403':'HTTP_403','/404':'HTTP_404','/429':'HTTP_429','/503':'HTTP_503','/redirect-one':'IMAGE_OK','/timeout':'TIMEOUT','/loop':'REDIRECT_LOOP','/escape':'FIXTURE_ORIGIN_ESCAPE_BLOCKED'}
    results=[]
    try:
        for path,label in expected.items():
            r=diagnose(base+path,fixture_base=base,timeout=.1 if path=='/timeout' else 2)
            assert r['classification']==label,(path,r)
            results.append({'fixture':path,'expected':label,'passed':True,'actual':r})
        # These reject the request before a public-mode network connection.
        for url in ['http://127.0.0.1:1/private','https://127.0.0.1/private']:
            r=diagnose(url);assert r['classification'] in ['URL_NOT_PUBLIC_HTTPS','NONPUBLIC_ADDRESS'],r
            results.append({'fixture':'public-mode-private-target','passed':True,'actual':r})
    finally:server.shutdown();server.server_close();thread.join(timeout=2)
    return {'scope':'Actual local HTTP fixture requests, not production images or Google fetches','python':platform.python_version(),'pillow':PIL.__version__,'cases':results,'passed':len(results),'failed':0}

if __name__=='__main__':
    from pathlib import Path
    result=run();Path(sys.argv[1]).write_text(json.dumps(result,indent=2));print(json.dumps({'passed':result['passed'],'failed':result['failed'],'results':[(x['fixture'],x['actual']['classification']) for x in result['cases']]}))
Actual result record, including HTTP headers
{
  "scope": "Actual local HTTP fixture requests, not production images or Google fetches",
  "python": "3.9.6",
  "pillow": "11.3.0",
  "cases": [
    {
      "fixture": "/image",
      "expected": "IMAGE_OK",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/image",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/image",
            "status": 200,
            "headers": {
              "Content-Type": "image/png",
              "Cache-Control": "public, max-age=60",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "IMAGE_OK",
        "google_fetch_verified": false,
        "bytes_read": 1842,
        "image": {
          "width": 500,
          "height": 500,
          "format": "PNG",
          "bytes": 1842
        }
      }
    },
    {
      "fixture": "/html",
      "expected": "HTML_INSTEAD_OF_IMAGE",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/html",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/html",
            "status": 200,
            "headers": {
              "Content-Type": "text/html",
              "Cache-Control": "public, max-age=60",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "HTML_INSTEAD_OF_IMAGE",
        "google_fetch_verified": false,
        "bytes_read": 48
      }
    },
    {
      "fixture": "/403",
      "expected": "HTTP_403",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/403",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/403",
            "status": 403,
            "headers": {
              "Content-Type": "text/plain",
              "Cache-Control": "public, max-age=60",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "HTTP_403",
        "google_fetch_verified": false
      }
    },
    {
      "fixture": "/404",
      "expected": "HTTP_404",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/404",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/404",
            "status": 404,
            "headers": {
              "Content-Type": "text/plain",
              "Cache-Control": "public, max-age=60",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "HTTP_404",
        "google_fetch_verified": false
      }
    },
    {
      "fixture": "/429",
      "expected": "HTTP_429",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/429",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/429",
            "status": 429,
            "headers": {
              "Content-Type": "text/plain",
              "Cache-Control": "public, max-age=60",
              "Retry-After": "120",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "HTTP_429",
        "google_fetch_verified": false
      }
    },
    {
      "fixture": "/503",
      "expected": "HTTP_503",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/503",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/503",
            "status": 503,
            "headers": {
              "Content-Type": "text/plain",
              "Cache-Control": "public, max-age=60",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "HTTP_503",
        "google_fetch_verified": false
      }
    },
    {
      "fixture": "/redirect-one",
      "expected": "IMAGE_OK",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/redirect-one",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/redirect-one",
            "status": 302,
            "headers": {
              "Content-Type": "image/png",
              "Cache-Control": "public, max-age=60",
              "Location": "http://127.0.0.1:49807/redirect-two",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          },
          {
            "url": "http://127.0.0.1:49807/redirect-two",
            "status": 301,
            "headers": {
              "Content-Type": "image/png",
              "Cache-Control": "public, max-age=60",
              "Location": "http://127.0.0.1:49807/image",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          },
          {
            "url": "http://127.0.0.1:49807/image",
            "status": 200,
            "headers": {
              "Content-Type": "image/png",
              "Cache-Control": "public, max-age=60",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "IMAGE_OK",
        "google_fetch_verified": false,
        "bytes_read": 1842,
        "image": {
          "width": 500,
          "height": 500,
          "format": "PNG",
          "bytes": 1842
        }
      }
    },
    {
      "fixture": "/timeout",
      "expected": "TIMEOUT",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/timeout",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [],
        "classification": "TIMEOUT",
        "google_fetch_verified": false
      }
    },
    {
      "fixture": "/loop",
      "expected": "REDIRECT_LOOP",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/loop",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/loop",
            "status": 302,
            "headers": {
              "Content-Type": "image/png",
              "Cache-Control": "public, max-age=60",
              "Location": "http://127.0.0.1:49807/loop",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "REDIRECT_LOOP",
        "google_fetch_verified": false
      }
    },
    {
      "fixture": "/escape",
      "expected": "FIXTURE_ORIGIN_ESCAPE_BLOCKED",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:49807/escape",
        "mode": "local_fixture",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [
          {
            "url": "http://127.0.0.1:49807/escape",
            "status": 302,
            "headers": {
              "Content-Type": "image/png",
              "Cache-Control": "public, max-age=60",
              "Location": "http://127.0.0.1:1/private",
              "Server": "BaseHTTP/0.6 Python/3.9.6"
            }
          }
        ],
        "classification": "FIXTURE_ORIGIN_ESCAPE_BLOCKED",
        "google_fetch_verified": false
      }
    },
    {
      "fixture": "public-mode-private-target",
      "passed": true,
      "actual": {
        "input": "http://127.0.0.1:1/private",
        "mode": "public_https",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [],
        "classification": "URL_NOT_PUBLIC_HTTPS",
        "google_fetch_verified": false
      }
    },
    {
      "fixture": "public-mode-private-target",
      "passed": true,
      "actual": {
        "input": "https://127.0.0.1/private",
        "mode": "public_https",
        "user_agent": "RewarxReadOnlyImageDiagnostic/1.0",
        "hops": [],
        "classification": "NONPUBLIC_ADDRESS",
        "google_fetch_verified": false
      }
    }
  ],
  "passed": 12,
  "failed": 0
}

Request limits and interpretation

Public mode accepts HTTPS on port 443, rejects non-public resolved addresses and pins each connection to the validated address while verifying TLS. Every redirect destination is checked again. The limit is three redirects/four requests, with a byte cap, socket timeouts and an overall request timer. DNS lookup still depends on the operating system resolver. Local HTTP is available only through the explicit fixture origin; redirects cannot escape that origin. Query strings are redacted in the saved diagnostic URLs.

There is no automatic retry, login, upload, WAF bypass or production write. The client identifies itself as RewarxReadOnlyImageDiagnostic/1.0. Changing a user-agent string would not establish that a request originated from Google. Treat one failed request as evidence to investigate, not proof of a platform-wide outage.

Investigate access controls with the owner

The Google crawl-issues reference includes missing URLs, authentication, DNS, robots availability, server responses and redirect failures. Inspect the exact image URL and relevant access logs. This script does not fetch or parse robots.txt; use the official inspection resources and review the rules for the relevant crawler.

Cloudflare’s troubleshooting documentation recommends correlating crawler errors with origin/CDN behavior and verifying Google crawler identities. Use verified crawler evidence and narrowly scoped rule changes with the site’s operator. Do not disable the site’s security controls globally, and do not assume a browser session or a successful local request proves Google can fetch the file.

Connect delivery, specifications and import

Keep the sequence inspectable: generate or select the image, verify its publicly retrievable asset URL, check image specifications, then validate the import mapping. Rewarx already supports Shopify import. The independent scripts in these guides test HTTP delivery, image files and CSV structure; their local fixtures do not test the existing product importer.

  • Image dimensions, format and byte preflight
  • Shopify variant CSV mapping preflight
  • XMP preservation during local resizing
  • Image accuracy and disclaimer limits
  • Diagnose generated-image artifacts

Inspect Rewarx’s background-removal entry page. Its primary link leads into Studio authentication. No new importer, export or pricing claim is established by this HTTP experiment.

Sources and evidence scope

  • Google image processing and delays
  • Google product crawl issues
  • Cloudflare crawl troubleshooting

Checked November 4, 2026. Executed versions: Python 3.9.6, Pillow 11.3.0. The cover summarizes these local fixture results; it is not a screenshot of Merchant Center or a Google test. No customer feed, paid API or real merchant image was modified.

https://www.rewarx.com/blogs/merchant-center-image-crawl-errors

Rewarx Studio | AI-Powered Product Photography & Image Generator

Turn snapshots into professional, high-converting product photos in batches. Cut costs by 90% and launch your collection in minutes.

Create Stunning Product Photos in Batches

Rewarx Studio is fine-tuned to understand the material physics and lighting requirements of 20+ specialized industries, including electronics, cosmetics, fashion, jewelry, home decor, and beverages.

Our virtual photography studio provides precise control over lighting, depth, and material textures. Perfect for high-end catalog shots, Etsy, Amazon, Shopify, and eBay sellers.

The Full AI Production Suite

  • AI Photography Studio: Professional virtual photography with precise control over lighting and textures.
  • AI Lookalike Creator: Match the aesthetic, lighting, and composition of any reference photo.
  • AI Model Studio: Integrate professional human models with your products naturally with realistic shadows.
  • AI Ghost Mannequin: Create a 3D "Invisible" mannequin effect showing inner linings and volume.
  • AI Mockup Generator: Apply patterns and graphics onto 3D items with absolute physical accuracy.
  • AI Group Shot Studio: Cohesively synthesize multiple products into a single scene with perfect lighting.
  • AI Product Page Builder: Generate conversion-optimized listing asset sets in a single click.
  • AI Commercial Ad Poster: Combine product focal points with premium typography for high-converting ads.

Corporate Headquarters

Rewarx Limited, Suite 400, 548 Market Street, San Francisco, CA 94104, United States. Email: studio@rewarx.com