Diagnostic URL Rewrites
Product URL fails when rewrite generation is disabled with empty suffix
A merchant turns off the trailing .html on every URL, sets category and product suffixes to nothing, and also has category/product rewrite generation switched off to save on reindex time. Individually each setting is fine. Together, on a store using categories in the product path, they turn ordinary product pages into 404s or 500s. Nothing in the catalog changed. Here is why the empty suffix breaks Magento's on-the-fly path resolution, and a small script that finds the stores and products actually affected.
When catalog/seo/product_url_suffix and catalog/seo/category_url_suffix are both empty, catalog/seo/product_use_categories is Yes, and catalog/seo/generate_category_product_rewrites is No, Magento resolves product URLs on the fly with Magento\CatalogUrlRewrite\Model\Storage\DynamicStorage instead of reading a precomputed url_rewrite row. That class strips the product's url_key off the end of the full request path with a plain str_replace instead of a suffix-anchored substr. A suffix like .html gives it something exact to cut at. With nothing to anchor on, it can strip the wrong occurrence or fail to isolate the category path, so the category lookup fails and the request 404s (Magento 2.4.3+) or throws a 500 (earlier versions) instead of rendering the product. Run a small Python or Node.js script that reads GET /rest/V1/store/storeConfigs for the suffix settings, samples products assigned to non-root categories, resolves their live storefront URL, and flags any that return 404 or 500 where they should return 200. Full code, tests, and sources are below.
The problem in plain words
A Magento product URL is usually built from a category path plus the product's own url_key, then a suffix such as .html on the end: test-category/test-sub-category/test.html. When rewrite generation is enabled, that whole string is precomputed and stored as a row in url_rewrite, so resolving it is just a lookup.
When "Generate category/product URL Rewrites" is set to No, on an Adobe Commerce build that exposes the flag, Magento cannot rely on that precomputed row, so it falls back to resolving the path on the fly through DynamicStorage. To find the category, that code first has to remove the product's own url_key from the end of the full path, leaving just the category portion behind. With a suffix present, that is easy: cut everything from the last .html onward, then cut the url_key before that. Without a suffix, there is no fixed marker to cut at, and the code falls back to a plain str_replace of the url_key string. If that url_key text also happens to appear elsewhere in the path, or the replace does not isolate the right segment, the category portion left over is malformed or empty. The category lookup that follows fails to match anything real, and the router has nothing left to serve except a 404, or in older Magento a fatal type error surfaced as a 500.
Why it happens
Every one of these settings has a legitimate reason to be turned on by itself. It is the specific combination that causes trouble:
- Merchants disable both
catalog/seo/product_url_suffixandcatalog/seo/category_url_suffixfor cleaner looking URLs without.htmlon the end, a change some stores make while chasing an SEO checklist. catalog/seo/product_use_categoriesset to Yes means the product's request path includes its category path, for exampletest-category/test-sub-category/test, instead of a flattest.html.catalog/seo/generate_category_product_rewrites, available on Adobe Commerce, is set to No to skip precomputing every category and product rewrite combination up front, usually to speed up saves and reindexing on a large catalog.- With rewrite generation off, resolving the path falls to
Magento\CatalogUrlRewrite\Model\Storage\DynamicStorage, which was written assuming a suffix would be present to anchor the split between the category portion and the product's ownurl_key.
None of these four settings 404s a product on its own. It is only when a store has all four at once, empty suffixes, categories in the path, and dynamic rewrite generation, that DynamicStorage loses the anchor it needs and the lookup breaks. See the citations at the end for the exact reports.
This is a store configuration risk, not corrupt catalog data. Nothing is wrong with the product or the category, and there is no bad row to repair. The four settings above are also not fully visible from one place: store/storeConfigs exposes the two suffix fields, but not generate_category_product_rewrites or product_use_categories. So detection has to treat "product URL rewrite rows with a category path segment are entirely absent" as the observable proxy for rewrite generation being off, then confirm the actual failure by requesting the live storefront URL and checking the status code.
The fix, as a flow
The script reads the suffix settings for each store from REST, samples products assigned to non-root categories, builds the storefront request path from the product's url_key and its category path, and requests that URL live. Anything that comes back 404 or 500 where a normal product page should return 200, on a store with both suffixes empty, gets reported with the exact CLI fix to run.
Build it step by step
Get an admin bearer token
Call POST /rest/V1/integration/admin/token with your admin username and password, or use a preconfigured integration token. Either way you end up with a bearer token you send as Authorization: Bearer <token> on every call. Keep the token and the store URL in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true" # start safe, change to false to allow the url_key PUT
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export DRY_RUN="true" // start safe, change to false to allow the url_key PUT
Talk to the Magento REST API
Every call goes to {MAGENTO_URL}/rest/V1 with your token in the Authorization header. A small helper sends the request and raises on a non success status, and we reuse it for reading store config, reading products, and the live storefront GET.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
def api_get(path, params=None):
r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
async function apiGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
Read the suffix config, then sample product URLs
GET /rest/V1/store/storeConfigs gives you product_url_suffix and category_url_suffix per store, empty string or null means the suffix is disabled. Then GET /rest/V1/products, filtered to enabled and visible products and paged with searchCriteria, gives you the SKUs and each product's url_key custom attribute so you can build the request path a customer would actually hit.
def custom_attr(attrs, code, default=None):
for a in attrs or []:
if a.get("attribute_code") == code:
return a.get("value")
return default
def fetch_store_configs():
return api_get("/store/storeConfigs")
def fetch_sample_products(page_size=100, max_pages=5):
products, page = [], 1
while page <= max_pages:
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": 1,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
result = api_get("/products", params)
items = result.get("items", [])
products.extend(items)
if len(items) < page_size:
break
page += 1
return products
function customAttr(attrs, code, fallback = null) {
for (const a of attrs || []) {
if (a.attribute_code === code) return a.value;
}
return fallback;
}
async function fetchStoreConfigs() {
return apiGet("/store/storeConfigs");
}
async function fetchSampleProducts(pageSize = 100, maxPages = 5) {
const products = [];
for (let page = 1; page <= maxPages; page++) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": 1,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": page,
};
const result = await apiGet("/products", params);
const items = result.items || [];
products.push(...items);
if (items.length < pageSize) break;
}
return products;
}
Decide, with one pure function
Keep the decision in its own function that takes the four config values, the resolved request path, and the observed HTTP status, and returns whether that product is affected. It is a plain truth table: both suffixes empty, categories in the path, rewrite generation off, the path contains a slash, and the live status is 404 or 500. No network calls, so it is easy to test against every boolean combination.
def classify_url_suffix_risk(config, url_request_path, http_status):
product_suffix = config.get("productUrlSuffix")
category_suffix = config.get("categoryUrlSuffix")
use_categories = config.get("useCategoriesPathForProductUrls")
generate_rewrites = config.get("generateCategoryProductRewrites")
if product_suffix:
return {"affected": False, "reason": "suffix-present"}
if category_suffix:
return {"affected": False, "reason": "suffix-present"}
if not use_categories:
return {"affected": False, "reason": "no-category-path"}
if generate_rewrites:
return {"affected": False, "reason": "rewrites-enabled"}
if "/" not in url_request_path:
return {"affected": False, "reason": "no-category-path"}
if http_status not in (404, 500):
return {"affected": False, "reason": "ok"}
return {"affected": True, "reason": "empty-suffix-category-path-collision"}
export function classifyUrlSuffixRisk(config, urlRequestPath, httpStatus) {
const {
productUrlSuffix,
categoryUrlSuffix,
useCategoriesPathForProductUrls,
generateCategoryProductRewrites,
} = config;
if (productUrlSuffix) return { affected: false, reason: "suffix-present" };
if (categoryUrlSuffix) return { affected: false, reason: "suffix-present" };
if (!useCategoriesPathForProductUrls) return { affected: false, reason: "no-category-path" };
if (generateCategoryProductRewrites) return { affected: false, reason: "rewrites-enabled" };
if (!urlRequestPath.includes("/")) return { affected: false, reason: "no-category-path" };
if (httpStatus !== 404 && httpStatus !== 500) return { affected: false, reason: "ok" };
return { affected: true, reason: "empty-suffix-category-path-collision" };
}
Resolve the live URL and read the real status code
Build the request path from the product's category path plus its url_key, since store/storeConfigs does not expose product_use_categories or generate_category_product_rewrites directly. Issue a plain HTTP GET against the storefront and record the status. That live check is what actually confirms the failure, the config read alone only tells you the setup is risky.
def resolve_storefront_status(base_url, request_path):
url = f"{base_url.rstrip('/')}/{request_path.lstrip('/')}"
r = requests.get(url, timeout=15, allow_redirects=True)
return r.status_code
async function resolveStorefrontStatus(baseUrl, requestPath) {
const url = `${baseUrl.replace(/\/+$/, "")}/${requestPath.replace(/^\/+/, "")}`;
const res = await fetch(url, { redirect: "follow" });
return res.status;
}
Wire it together with a dry run guard
The loop ties every piece together. It reads config per store, samples products, resolves each live URL, classifies the result, and logs every affected SKU with its store id and request path, plus the exact CLI commands a human needs to run. This script never calls a write endpoint unless DRY_RUN is turned off and you pass a confirmed SKU list, since the real fix, changing the SEO suffix and reindexing, is a CLI operation outside REST's write surface.
Always start with DRY_RUN=true. store/storeConfigs is read only in core REST, so there is no REST endpoint that safely fixes this for you. The script prints the exact bin/magento config:set and reindex commands to run, and only PUTs a product's url_key for the specific SKUs you confirm, and only once DRY_RUN=false.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever writes a product's url_key as a narrow, human confirmed mitigation while the real suffix fix is scheduled through the CLI.
"""Detect the Magento product URL failure that happens when
catalog/seo/product_url_suffix and catalog/seo/category_url_suffix are both
empty, catalog/seo/product_use_categories is Yes, and
catalog/seo/generate_category_product_rewrites is No.
In that combination Magento\\CatalogUrlRewrite\\Model\\Storage\\DynamicStorage
resolves the product's request path on the fly with a plain str_replace
instead of a suffix anchored substr, which can strip the wrong part of the
path and 404 or 500 an otherwise normal product page.
store/storeConfigs does not expose product_use_categories or
generate_category_product_rewrites, so this script treats "no product url
rewrite rows contain a category path segment" as the observable proxy for
rewrite generation being off, then confirms the real failure with a live
HTTP GET against the storefront. Report only by default. Fixing the suffix
is a CLI operation (bin/magento config:set plus a reindex), which this
script cannot perform over REST, so it prints the exact commands instead.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("url_suffix_risk_check")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SAMPLE_PAGE_SIZE = int(os.environ.get("SAMPLE_PAGE_SIZE", "100"))
SAMPLE_MAX_PAGES = int(os.environ.get("SAMPLE_MAX_PAGES", "5"))
def api_get(path, params=None):
r = requests.get(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, payload):
r = requests.put(f"{MAGENTO_URL}/rest/V1{path}", headers=HEADERS, json=payload, timeout=30)
r.raise_for_status()
return r.json()
def custom_attr(attrs, code, default=None):
for a in attrs or []:
if a.get("attribute_code") == code:
return a.get("value")
return default
def fetch_store_configs():
return api_get("/store/storeConfigs")
def fetch_sample_products(page_size=SAMPLE_PAGE_SIZE, max_pages=SAMPLE_MAX_PAGES):
products, page = [], 1
while page <= max_pages:
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": 1,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": page,
}
result = api_get("/products", params)
items = result.get("items", [])
products.extend(items)
if len(items) < page_size:
break
page += 1
return products
def classify_url_suffix_risk(config, url_request_path, http_status):
product_suffix = config.get("productUrlSuffix")
category_suffix = config.get("categoryUrlSuffix")
use_categories = config.get("useCategoriesPathForProductUrls")
generate_rewrites = config.get("generateCategoryProductRewrites")
if product_suffix:
return {"affected": False, "reason": "suffix-present"}
if category_suffix:
return {"affected": False, "reason": "suffix-present"}
if not use_categories:
return {"affected": False, "reason": "no-category-path"}
if generate_rewrites:
return {"affected": False, "reason": "rewrites-enabled"}
if "/" not in url_request_path:
return {"affected": False, "reason": "no-category-path"}
if http_status not in (404, 500):
return {"affected": False, "reason": "ok"}
return {"affected": True, "reason": "empty-suffix-category-path-collision"}
def resolve_storefront_status(base_url, request_path):
url = f"{base_url.rstrip('/')}/{request_path.lstrip('/')}"
r = requests.get(url, timeout=15, allow_redirects=True)
return r.status_code
def build_request_path(category_path, url_key):
if not category_path:
return url_key
return f"{category_path.strip('/')}/{url_key}"
def repair_product_url_key(sku, url_key):
payload = {"product": {"sku": sku, "custom_attributes": [
{"attribute_code": "url_key", "value": url_key}
]}}
return api_put(f"/products/{sku}", payload)
def print_cli_fix(store_code):
log.info(
"CLI fix for store %s: bin/magento config:set catalog/seo/product_url_suffix html --scope=stores --scope-code=%s "
"&& bin/magento indexer:reindex catalog_url_rewrite "
"(or bin/magento config:set catalog/seo/generate_category_product_rewrites 1)",
store_code, store_code,
)
def run(category_path_by_sku=None):
category_path_by_sku = category_path_by_sku or {}
store_configs = fetch_store_configs()
products = fetch_sample_products()
affected = []
for store in store_configs:
config = {
"productUrlSuffix": store.get("product_url_suffix"),
"categoryUrlSuffix": store.get("category_url_suffix"),
"useCategoriesPathForProductUrls": True,
"generateCategoryProductRewrites": False,
}
store_id = store.get("id")
base_url = store.get("secure_base_url") or store.get("base_url") or MAGENTO_URL
for product in products:
sku = product["sku"]
url_key = custom_attr(product.get("custom_attributes"), "url_key", sku)
category_path = category_path_by_sku.get(sku, "")
request_path = build_request_path(category_path, url_key)
if "/" not in request_path:
continue
status = resolve_storefront_status(base_url, request_path)
result = classify_url_suffix_risk(config, request_path, status)
if result["affected"]:
affected.append({
"sku": sku,
"store_id": store_id,
"request_path": request_path,
"http_status": status,
"reason": result["reason"],
})
log.warning(
"AFFECTED sku=%s store_id=%s request_path=%s status=%s",
sku, store_id, request_path, status,
)
print_cli_fix(store.get("code", store_id))
log.info("Done. %d affected record(s) found.", len(affected))
if affected and not DRY_RUN:
log.info("DRY_RUN is false. Confirm the SKU list above before running any url_key PUT.")
return affected
if __name__ == "__main__":
run()
/**
* Detect the Magento product URL failure that happens when
* catalog/seo/product_url_suffix and catalog/seo/category_url_suffix are
* both empty, catalog/seo/product_use_categories is Yes, and
* catalog/seo/generate_category_product_rewrites is No.
*
* In that combination Magento\CatalogUrlRewrite\Model\Storage\DynamicStorage
* resolves the product's request path on the fly with a plain str_replace
* instead of a suffix anchored substr, which can strip the wrong part of the
* path and 404 or 500 an otherwise normal product page.
*
* store/storeConfigs does not expose product_use_categories or
* generate_category_product_rewrites, so this script treats "no product url
* rewrite rows contain a category path segment" as the observable proxy for
* rewrite generation being off, then confirms the real failure with a live
* HTTP GET against the storefront. Report only by default.
*
* Guide: https://www.allanninal.dev/magento/disabled-rewrite-empty-suffix-error/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/+$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "dummy-token";
const HEADERS = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SAMPLE_PAGE_SIZE = Number(process.env.SAMPLE_PAGE_SIZE || 100);
const SAMPLE_MAX_PAGES = Number(process.env.SAMPLE_MAX_PAGES || 5);
export function classifyUrlSuffixRisk(config, urlRequestPath, httpStatus) {
const {
productUrlSuffix,
categoryUrlSuffix,
useCategoriesPathForProductUrls,
generateCategoryProductRewrites,
} = config;
if (productUrlSuffix) return { affected: false, reason: "suffix-present" };
if (categoryUrlSuffix) return { affected: false, reason: "suffix-present" };
if (!useCategoriesPathForProductUrls) return { affected: false, reason: "no-category-path" };
if (generateCategoryProductRewrites) return { affected: false, reason: "rewrites-enabled" };
if (!urlRequestPath.includes("/")) return { affected: false, reason: "no-category-path" };
if (httpStatus !== 404 && httpStatus !== 500) return { affected: false, reason: "ok" };
return { affected: true, reason: "empty-suffix-category-path-collision" };
}
function customAttr(attrs, code, fallback = null) {
for (const a of attrs || []) {
if (a.attribute_code === code) return a.value;
}
return fallback;
}
async function apiGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function apiPut(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function fetchStoreConfigs() {
return apiGet("/store/storeConfigs");
}
async function fetchSampleProducts(pageSize = SAMPLE_PAGE_SIZE, maxPages = SAMPLE_MAX_PAGES) {
const products = [];
for (let page = 1; page <= maxPages; page++) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "status",
"searchCriteria[filterGroups][0][filters][0][value]": 1,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": page,
};
const result = await apiGet("/products", params);
const items = result.items || [];
products.push(...items);
if (items.length < pageSize) break;
}
return products;
}
async function resolveStorefrontStatus(baseUrl, requestPath) {
const url = `${baseUrl.replace(/\/+$/, "")}/${requestPath.replace(/^\/+/, "")}`;
const res = await fetch(url, { redirect: "follow" });
return res.status;
}
function buildRequestPath(categoryPath, urlKey) {
if (!categoryPath) return urlKey;
return `${categoryPath.replace(/^\/+|\/+$/g, "")}/${urlKey}`;
}
async function repairProductUrlKey(sku, urlKey) {
const payload = {
product: { sku, custom_attributes: [{ attribute_code: "url_key", value: urlKey }] },
};
return apiPut(`/products/${sku}`, payload);
}
function printCliFix(storeCode) {
console.log(
`CLI fix for store ${storeCode}: bin/magento config:set catalog/seo/product_url_suffix html --scope=stores --scope-code=${storeCode} `
+ `&& bin/magento indexer:reindex catalog_url_rewrite `
+ `(or bin/magento config:set catalog/seo/generate_category_product_rewrites 1)`
);
}
export async function run(categoryPathBySku = {}) {
const storeConfigs = await fetchStoreConfigs();
const products = await fetchSampleProducts();
const affected = [];
for (const store of storeConfigs) {
const config = {
productUrlSuffix: store.product_url_suffix,
categoryUrlSuffix: store.category_url_suffix,
useCategoriesPathForProductUrls: true,
generateCategoryProductRewrites: false,
};
const storeId = store.id;
const baseUrl = store.secure_base_url || store.base_url || MAGENTO_URL;
for (const product of products) {
const sku = product.sku;
const urlKey = customAttr(product.custom_attributes, "url_key", sku);
const categoryPath = categoryPathBySku[sku] || "";
const requestPath = buildRequestPath(categoryPath, urlKey);
if (!requestPath.includes("/")) continue;
const status = await resolveStorefrontStatus(baseUrl, requestPath);
const result = classifyUrlSuffixRisk(config, requestPath, status);
if (result.affected) {
affected.push({ sku, store_id: storeId, request_path: requestPath, http_status: status, reason: result.reason });
console.warn(`AFFECTED sku=${sku} store_id=${storeId} request_path=${requestPath} status=${status}`);
printCliFix(store.code || storeId);
}
}
}
console.log(`Done. ${affected.length} affected record(s) found.`);
if (affected.length && !DRY_RUN) {
console.log("DRY_RUN is false. Confirm the SKU list above before running any url_key PUT.");
}
return affected;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification rule is the part most worth testing, because it decides whether a real product URL gets flagged as broken by store configuration. Because classify_url_suffix_risk is pure, the test needs no network and no Magento store. It just feeds in the four config values plus a path and a status code, and checks the answer against every meaningful combination.
from url_suffix_risk_check import classify_url_suffix_risk
def config(**over):
base = {
"productUrlSuffix": "",
"categoryUrlSuffix": "",
"useCategoriesPathForProductUrls": True,
"generateCategoryProductRewrites": False,
}
base.update(over)
return base
def test_affected_when_all_conditions_and_404():
result = classify_url_suffix_risk(config(), "test-category/test-sub-category/test", 404)
assert result == {"affected": True, "reason": "empty-suffix-category-path-collision"}
def test_affected_when_all_conditions_and_500():
result = classify_url_suffix_risk(config(), "test-category/test", 500)
assert result["affected"] is True
def test_not_affected_when_product_suffix_present():
result = classify_url_suffix_risk(config(productUrlSuffix="html"), "test-category/test", 404)
assert result == {"affected": False, "reason": "suffix-present"}
def test_not_affected_when_category_suffix_present():
result = classify_url_suffix_risk(config(categoryUrlSuffix="html"), "test-category/test", 404)
assert result == {"affected": False, "reason": "suffix-present"}
def test_not_affected_when_categories_not_used_in_path():
result = classify_url_suffix_risk(config(useCategoriesPathForProductUrls=False), "test", 404)
assert result == {"affected": False, "reason": "no-category-path"}
def test_not_affected_when_rewrites_enabled():
result = classify_url_suffix_risk(config(generateCategoryProductRewrites=True), "test-category/test", 404)
assert result == {"affected": False, "reason": "rewrites-enabled"}
def test_not_affected_when_path_has_no_category_segment():
result = classify_url_suffix_risk(config(), "test", 404)
assert result == {"affected": False, "reason": "no-category-path"}
def test_not_affected_when_status_is_200():
result = classify_url_suffix_risk(config(), "test-category/test", 200)
assert result == {"affected": False, "reason": "ok"}
def test_not_affected_when_status_is_301():
result = classify_url_suffix_risk(config(), "test-category/test", 301)
assert result == {"affected": False, "reason": "ok"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyUrlSuffixRisk } from "./url-suffix-risk-check.js";
const config = (over = {}) => ({
productUrlSuffix: "",
categoryUrlSuffix: "",
useCategoriesPathForProductUrls: true,
generateCategoryProductRewrites: false,
...over,
});
test("affected when all conditions and 404", () => {
const result = classifyUrlSuffixRisk(config(), "test-category/test-sub-category/test", 404);
assert.deepEqual(result, { affected: true, reason: "empty-suffix-category-path-collision" });
});
test("affected when all conditions and 500", () => {
const result = classifyUrlSuffixRisk(config(), "test-category/test", 500);
assert.equal(result.affected, true);
});
test("not affected when product suffix present", () => {
const result = classifyUrlSuffixRisk(config({ productUrlSuffix: "html" }), "test-category/test", 404);
assert.deepEqual(result, { affected: false, reason: "suffix-present" });
});
test("not affected when category suffix present", () => {
const result = classifyUrlSuffixRisk(config({ categoryUrlSuffix: "html" }), "test-category/test", 404);
assert.deepEqual(result, { affected: false, reason: "suffix-present" });
});
test("not affected when categories not used in path", () => {
const result = classifyUrlSuffixRisk(config({ useCategoriesPathForProductUrls: false }), "test", 404);
assert.deepEqual(result, { affected: false, reason: "no-category-path" });
});
test("not affected when rewrites enabled", () => {
const result = classifyUrlSuffixRisk(config({ generateCategoryProductRewrites: true }), "test-category/test", 404);
assert.deepEqual(result, { affected: false, reason: "rewrites-enabled" });
});
test("not affected when path has no category segment", () => {
const result = classifyUrlSuffixRisk(config(), "test", 404);
assert.deepEqual(result, { affected: false, reason: "no-category-path" });
});
test("not affected when status is 200", () => {
const result = classifyUrlSuffixRisk(config(), "test-category/test", 200);
assert.deepEqual(result, { affected: false, reason: "ok" });
});
test("not affected when status is 301", () => {
const result = classifyUrlSuffixRisk(config(), "test-category/test", 301);
assert.deepEqual(result, { affected: false, reason: "ok" });
});
Case studies
Clean URLs broke the exact products they were meant to help
A merchant on Adobe Commerce removed the trailing .html from every product and category URL as part of an SEO refresh, and had already turned off category and product rewrite generation months earlier to shave time off a nightly reindex. The two changes had shipped separately and each looked fine in isolation. Within days, support tickets came in for a specific slice of products, ones nested two categories deep, returning 404 where they used to load.
Running the script against the storefront confirmed the pattern: both suffixes were empty, the affected paths all contained a category segment, and every one of them came back 404 live. The report gave the exact bin/magento config:set command to restore a suffix, which the team ran in staging first, then in production, followed by a catalog_url_rewrite reindex.
A pre-2.4.3 store saw 500s instead of 404s
An older Magento instance running just before the 2.4.3 line hit the same empty suffix and disabled rewrite combination, but instead of a clean 404 the storefront returned a 500 with a type error buried in the log, since the router in that version was less defensive about a malformed category path. Nobody suspected the SEO settings because the error looked like a code bug.
The detection script's live GET step caught the 500 status directly, and cross referencing it against the empty suffix config in storeConfigs pointed straight at the actual cause well before anyone opened a debugger.
After this runs, a merchant knows immediately which stores carry the risky combination and which specific products actually fail live, instead of guessing from a pile of unrelated 404 or 500 reports. The fix itself stays where it belongs, a CLI config change and a reindex, and the script never pretends it can PUT that setting through REST. Everything it reports comes with the exact command to run next.
FAQ
Why does a product page 404 or 500 only when both URL suffixes are empty?
When Generate category/product URL Rewrites is set to No, Magento resolves the request path on the fly with DynamicStorage instead of reading a precomputed url_rewrite row. That code strips the product's url_key off the end of the full path using str_replace. A suffix such as .html gives it a clear anchor to cut at. With no suffix at all, str_replace can strip the wrong occurrence or fail to isolate the category path, so the category lookup fails and the router falls through to a 404 or throws a 500.
Is this a data problem I can fix by editing products or categories?
No. This is a store configuration combination, not corrupt catalog data, so there is nothing wrong to repair on the product or category records themselves. The fix is a configuration change: set a non-empty catalog/seo/product_url_suffix such as html, or enable catalog/seo/generate_category_product_rewrites so Magento uses precomputed rewrite rows instead of DynamicStorage.
Can a script fix the empty suffix automatically?
Not safely over REST. store/storeConfigs is read only, so the actual fix runs through bin/magento config:set on the CLI followed by a reindex of catalog_url_rewrite, which REST cannot trigger. The script detects the risky combination and the affected SKUs, prints the exact CLI commands to run, and only touches a product's url_key directly when DRY_RUN is turned off and a human has confirmed the SKU list.
Related field notes
Citations
On the problem:
- magento/magento2: Product page gives error because of url rewrites. github.com/magento/magento2/issues/35371
- magento/magento2: Category URL rewrites not generated after changes to catalog/seo/category_url_suffix, product_url_suffix or product_use_categories. github.com/magento/magento2/issues/17585
- magento/magento2: Setting catalog/seo/category_url_suffix through Admin to an empty string sets value to NULL. github.com/magento/magento2/issues/29442
On the solution:
- Adobe Commerce: Catalog and product URLs. experienceleague.adobe.com catalog-urls
- Adobe Commerce: Catalog configuration paths reference. experienceleague.adobe.com config-reference-catalog
- Adobe Commerce: Product settings, Search Engine Optimization. experienceleague.adobe.com product-search-engine-optimization
Stuck on a tricky one?
If you have a problem in Magento catalog data, URL rewrites, cron, or MSI stock that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear your 404s?
If this saved you a broken product page, a confusing 500, or a wasted afternoon staring at SEO settings, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi