405 Method Not Allowed in Web Scraping: Causes and Fixes

A 405 Method Not Allowed response means the server found your URL but refused the HTTP method you sent. In scraping, that’s often not what actually happened. Some servers answer automated clients with a 405, which is why your script fails on a page that loads fine in Chrome.
This guide is for developers debugging a scraper in Python. It covers how to tell those two cases apart in about two minutes, then what to do about each.
What a 405 Method Not Allowed error means
A 405 is a client error. The server found the resource and understood the request, but the method you used isn’t on the list that resource accepts.
RFC 9110, which replaced RFC 7231 in June 2022, requires servers to send an Разрешить header with every 405 response, listing the methods they do support. Almost nobody debugging a scraper reads it. It’s the fastest way to separate a real method error from a refusal.
The same status code appears under different wordings depending on the server software:
- 405 Method Not Allowed
- HTTP Error 405 – Method Not Allowed
- 405 Not Allowed
- The requested method POST is not allowed for the URL
- HTTP verb used to access this page is not allowed
- This page isn’t working – HTTP ERROR 405
They all mean the same thing at the protocol level.
Why your scraper gets a 405 when your browser doesn’t
A 405 in a scraper comes from one of three places. Each needs a different fix, so guessing costs you time.
| Причина | Что вы видите | Confirms it |
|---|---|---|
| The method really is wrong | 405 on an API or form endpoint, every time | Разрешить header lists a method you didn’t use |
| The server is rejecting your client | 405 on a plain page that should accept GET | Same URL loads in Chrome |
| CORS preflight rejection | 405 on an OPTIONS request from browser automation | Only with injected cross-origin requests |
Why a server returns 405 instead of 403
On a normal page, GET is obviously allowed, so a 405 there isn’t really about the method. Some protection layers answer non-browser clients with a 405 instead of a 403 because it tells you less. A 403 says you were identified and refused. A 405 sends you off rewriting request methods.
What gives a Python client away usually isn’t the User-Agent string. It’s everything underneath it.
- TLS handshake.
запросыnegotiates TLS through OpenSSL. Its cipher list, extension order and supported groups don’t match Chrome’s, and that combination is stable enough to fingerprint. JA3 and JA4 hashes describe exactly this. - Protocol version. Chrome speaks HTTP/2 or HTTP/3 to most large sites.
запросыonly speaks HTTP/1.1. That splits the two apart before a single header gets read. - HTTP/2 settings. Clients that do use HTTP/2 send their own
SETTINGSvalues and window sizes, and those vary by library. - Header order. Browsers send headers in a consistent order.
запросыadds its own defaults in its own order, matching no browser.
So swapping in a Chrome User-Agent sometimes changes nothing. You fixed one signal out of four.
When the method really is wrong
The honest version of the error turns up when you’re hitting an endpoint you pulled out of DevTools rather than a page. Search endpoints, pagination handlers and login forms usually take POST only.
Open the Network tab, find the failing request, look at the Method column. If it says POST and your code sends GET, you’re done. None of the block diagnosis applies.
How to diagnose a 405 in two minutes
Run these in order. Stop when one gives you a clear answer.
1. Read the Разрешить заголовок. Fastest check, and it usually settles the question by itself.
python
import requests
url = "https://example.com/api/search"
response = requests.options(url, timeout=10)
print(response.status_code)
print(response.headers.get("Allow"))If it comes back listing GET and your GET was refused, the method isn’t your problem. If it lists POST only, it is.
2. Open the URL in a browser. Works in Chrome, fails in your script, same network: the difference is your client.
3. Check when it fails. Failing on request one points at how the request is built. Failing after fifty successful requests points at a rate limit.
4. Look at the response headers. cf-ray, server: cloudflare or Akamai headers mean a protection layer answered instead of the application.
5. Wait fifteen minutes and send the same request again, unchanged. If it works, you hit a temporary limit. Slow down rather than rewrite anything.
| Diagnosis | Иди |
|---|---|
Разрешить lists a method you didn’t use | Fix 1 |
| Loads in Chrome, fails in your script from request one | Fix 2 |
| Works, then stops after N requests | Fix 3 |
| Clears on its own after a wait | Fix 3, and lower your request rate |
Fix 1: send the method the endpoint expects
Easy once the Разрешить header has told you what to send. Reproduce the request DevTools showed you, payload and content type included.
python
import requests
url = "https://example.com/api/search"
payload = {"query": "laptops", "page": 1}
response = requests.post(url, json=payload, timeout=10)
print(response.status_code)Redirects catch people out here. If a request crosses a 301 or 302, some clients turn POST into GET along the way, so your method is right at the URL you wrote and wrong at the URL you reached. Print response.history and check what the final request looked like before you blame the endpoint.
Fix 2: send a complete request
If the endpoint takes your method but rejects your client, the request itself is incomplete. A browser sends a dozen headers on every navigation. A bare requests.get() sends four.
python
import requests
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Upgrade-Insecure-Requests": "1",
}
session = requests.Session()
session.headers.update(headers)
response = session.get("https://example.com/", timeout=10)
print(response.status_code)Использовать Сессия instead of standalone calls. It keeps cookies between requests and reuses the connection, the way a browser does. Independent requests that each open a fresh connection and carry no cookies look strange on any site with a login.
Set a Referer only where it would be true. A request arriving with a referrer from a page that doesn’t link to it is worse than sending none.
If a full header set changes nothing, what’s left is the TLS and protocol difference described above. curl_cffi и httpx с http2=True close part of that gap. A браузер для скрейпинга closes the rest and costs you speed, since it’s an actual browser engine.
Fix 3: spread requests across more IP addresses
When a 405 shows up only after a run of successful requests, or clears on its own after a wait, the trigger is volume from one address. Headers aren’t the issue. Rewriting them won’t help.
Slow down first. Add a delay between requests and keep concurrency modest. Sites publish their rate limits more often than people expect, and staying inside them is cheaper than engineering around them.
Then spread the load. Ротационные резидентские прокси hand out a different IP per request or per session, so a per-address limit stops being the ceiling on the whole job. Work that needs a session to hold, anything behind a login, suits a sticky session or a static IP better, because changing address mid-session sets off its own re-verification.
python
proxies = {
"http": "http://YOUR_USERNAME:YOUR_PASSWORD@PROXY_HOST:PROXY_PORT",
"https": "http://YOUR_USERNAME:YOUR_PASSWORD@PROXY_HOST:PROXY_PORT",
}
response = session.get(url, proxies=proxies, timeout=15)
print(response.status_code)IP quality matters here for a practical reason. Addresses flagged elsewhere arrive with that reputation attached, so rotating through a low-grade pool can fail more often than one clean address does. NodeMaven filters its pool before the IPs reach you.
Retry logic that doesn’t waste bandwidth
Most scrapers retry every non-200 the same way. With a 405 that’s the wrong default. A real method mismatch will fail identically forever, and every attempt costs traffic.
Classify once on the first failure, then act on it.
python
import time
import requests
def fetch(session, url, proxy_pool, max_attempts=3):
for attempt in range(max_attempts):
proxies = proxy_pool[attempt % len(proxy_pool)]
response = session.get(url, proxies=proxies, timeout=15)
if response.status_code != 405:
return response
allowed = response.headers.get("Allow", "")
if allowed and "GET" not in allowed:
raise ValueError(f"GET not supported here. Allowed: {allowed}")
time.sleep(2 ** attempt)
return NoneNever retry a confirmed method mismatch. Change the IP between attempts rather than repeating from the same one. Back off instead of hammering, because a temporary limit needs time more than it needs another request.
405 vs 403, 404, 401 and 415
These get confused constantly, since the symptom is the same. Your request didn’t go through.
| Код | Resource exists | Request well formed | Method allowed | Access allowed | Usual meaning in scraping |
|---|---|---|---|---|---|
| 400 Неверный запрос | n/a | Нет | n/a | n/a | Malformed payload or bad parameters |
| 401 Не авторизовано | Да | Да | Да | Needs auth | Missing token or expired session |
| 403 Запрещено | Да | Да | Да | Нет | You were identified and refused |
| 404 Не найдено | Нет | Да | n/a | n/a | Wrong URL, or the resource is hidden from you |
| 405 Method Not Allowed | Да | Да | Нет | Да | Wrong method, or a refusal in disguise |
| 415 Unsupported Media Type | Да | Да | Да | Да | Wrong Content-Type on a POST |
A 403 and a 405 on the same site often mean the same thing in practice. A 403 just admits it.
How a 405 surfaces in different tools
| Инструмент | Что вы видите | Check first |
|---|---|---|
| запросы | response.status_code == 405, no exception raised | Whether you’re checking the status code at all |
| httpx | Same, unless you call raise_for_status() | Включить http2=True |
| Scrapy | Response dropped by default, 405 not in handle_httpstatus_list | DOWNLOAD_DELAY и CONCURRENT_REQUESTS_PER_DOMAIN |
| Playwright | response.status() on the navigation response | Whether the page failed or a background XHR did |
| curl | Body only, unless you pass -i или -в | Беги curl -X OPTIONS -i to read the Разрешить заголовок |
Scrapy is worth a note. It drops non-2xx responses before your parser runs, so a spider that returns nothing may be collecting 405s you never see. Add 405 to handle_httpstatus_list while you debug and the response reaches your callback.
When the answer is to stop
Sometimes a 405 is a site saying it doesn’t want automated traffic, and the right response is to leave it alone.
Проверить robots.txt and the terms before you spend more time on it. Look for an official API, which is usually faster and steadier than scraping a front end anyway. Data collection has to stay inside the law and the platform’s own terms, and everything here assumes publicly available data on sites where collecting it is allowed.





