Quay lại danh sách
Phát hiện và xử lý 403/429 khi scraping bằng proxy xoay trong Python

Phát hiện và xử lý 403/429 khi scraping bằng proxy xoay trong Python

24 tháng 9, 2026

Vì sao 403 và 429 là kẻ thù số một của scraper

Khi scraping quy mô vừa và lớn, hai mã trạng thái xuất hiện nhiều nhất và phá pipeline nhanh nhất là 403 Forbidden429 Too Many Requests. 403 thường nghĩa là target đã chặn IP hoặc phát hiện dấu hiệu bot qua fingerprint. 429 nghĩa là bạn đang vượt rate limit của endpoint. Cả hai đều có thể xuất hiện xen kẽ: ban đầu 429, sau khi retry liên tục thì chuyển thành 403 (IP bị block hẳn).

Proxy xoay giúp giảm xác suất bị chặn, nhưng nếu bạn không xử lý response đúng cách, proxy pool sẽ nhanh chóng cạn kiệt IP sạch và pipeline sập.

Bài này đi sâu vào một workflow cụ thể: nhận diện 403/429, quyết định retry hay bỏ, chọn proxy thay thế, và backoff hợp lý — toàn bộ bằng Python.

Nguyên tắc cốt lõi: không retry mù quáng

Nhiều scraper mới dùng vòng lặp while retry đến khi thành công. Đây là lỗi phổ biến. Nguyên tắc nên theo:

  • 403 không phải tạm thời: nếu một IP bị 403, retry ngay với cùng IP đó gần như chắc chắn vẫn 403. Cần đổi IP.
  • 429 có thể tạm thời: server báo bạn đang quá nhanh. Có thể retry sau khi chờ, nhưng tốt hơn là giảm tốc độ và đổi IP.
  • Retry quá nhiều = tự sát: nếu target đang bảo trì hoặc chặn toàn ASN, retry 100 lần chỉ tốn proxy và bandwidth.

Quy tắc retry đề xuất

Hành động Số lần retry tối đa
429 Backoff + đổi proxy 3
403 Đổi proxy ngay, không backoff dài 2
5xx Backoff ngắn, giữ proxy 2
200 Thành công 0
Khác (3xx, 4xx khác) Không retry 0

Thiết lập proxy pool với RoProxy

Giả sử bạn dùng RoProxy rotating residential. Endpoint có dạng:

http://USER:PASS@gw.roproxy.com:PORT

Với rotating, mỗi request mới tự động nhận IP khác. Nhưng để kiểm soát tốt hơn, ta sẽ duy trì danh sách endpoint theo vùng và xoay thủ công khi gặp lỗi.

import itertools
import random

PROXY_ENDPOINTS = [
    "http://user:pass@gw.roproxy.com:8000",  # US
    "http://user:pass@gw.roproxy.com:8001",  # EU
    "http://user:pass@gw.roproxy.com:8002",  # APAC
]

proxy_cycle = itertools.cycle(PROXY_ENDPOINTS)

def next_proxy():
    return next(proxy_cycle)

Lý do dùng itertools.cycle thay vì random thuần: cycle đảm bảo phân phối đều, tránh "bốc" liên tục vào cùng một endpoint đang bị chặn.

Hàm fetch có retry thông minh

Đây là phần trọng tâm. Ta kết hợp requests với logic retry dựa trên mã trạng thái.

import requests
import time
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

MAX_RETRIES_429 = 3
MAX_RETRIES_403 = 2
BASE_BACKOFF = 2  # giây

def fetch_with_smart_retry(url, headers=None, timeout=15):
    headers = headers or {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept-Language": "en-US,en;q=0.9",
    }

    retry_429 = 0
    retry_403 = 0

    while True:
        proxy = next_proxy()
        proxies = {"http": proxy, "https": proxy}

        try:
            resp = requests.get(url, headers=headers, proxies=proxies, timeout=timeout)
        except requests.exceptions.RequestException as e:
            print(f"[NETWORK] {e} — đổi proxy, retry sau 1s")
            time.sleep(1)
            continue

        if resp.status_code == 200:
            return resp

        if resp.status_code == 429:
            retry_429 += 1
            if retry_429 > MAX_RETRIES_429:
                raise Exception(f"Vượt giới hạn retry cho 429 tại {url}")
            wait = BASE_BACKOFF * (2 ** (retry_429 - 1)) + random.uniform(0, 1)
            print(f"[429] retry {retry_429}/{MAX_RETRIES_429}, chờ {wait:.1f}s, đổi proxy")
            time.sleep(wait)
            continue

        if resp.status_code == 403:
            retry_403 += 1
            if retry_403 > MAX_RETRIES_403:
                raise Exception(f"Vượt giới hạn retry cho 403 tại {url}")
            print(f"[403] đổi proxy ngay, retry {retry_403}/{MAX_RETRIES_403}")
            time.sleep(0.5)
            continue

        # Các mã khác: không retry
        return resp

Tại sao backoff có jitter

random.uniform(0, 1) thêm vào thời gian chờ giúp tránh "thundering herd": nếu nhiều worker cùng bị 429 và cùng retry đúng giây nguyên, server sẽ lại bị quá tải. Jitter nhỏ khiến các worker rải ra.

Tại sao 403 không backoff dài

403 thường là chặn IP, không phải quá tải. Chờ lâu không giúp gì. Đổi IP và retry nhanh là đúng chiến thuật. Nếu vẫn 403 sau 2 lần đổi IP, khả năng cao target đang chặn theo ASN hoặc fingerprint — cần dừng và xem xét strategy khác (đổi User-Agent, dùng residential thay datacenter, hoặc bỏ URL này).

Phát hiện CAPTCHA ngầm trong response 200

Một bẫy nguy hiểm: server trả 200 nhưng body là trang CAPTCHA, không phải dữ liệu thật. Nếu scraper chỉ check status code, nó sẽ "nghĩ" thành công và lưu rác.

def is_captcha_page(html: str) -> bool:
    signals = [
        "cf-challenge",
        "captcha",
        "recaptcha",
        "hcaptcha",
        "are you human",
        "verify you are human",
    ]
    lower = html.lower()
    return any(s in lower for s in signals)

def fetch_clean(url, **kw):
    resp = fetch_with_smart_retry(url, **kw)
    if resp.status_code == 200 and is_captcha_page(resp.text):
        raise Exception(f"CAPTCHA phát hiện trong body 200 tại {url}")
    return resp

Khi gặp CAPTCHA, không nên retry vô hạn. Tùy workflow:

  • Bỏ URL, ghi log, quay lại sau với proxy vùng khác.
  • Chuyển sang headless browser (Playwright/Puppeteer) có hỗ trợ proxy, để vượt challenge JS.
  • Gửi URL vào hàng đợi CAPTCHA riêng, xử lý bằng dịch vụ giải CAPTCHA nếu nghiệp vụ cho phép.

Giới hạn tốc độ chủ động

Xử lý 429 tốt hơn là phòng tránh nó. Dù có proxy xoay, bạn vẫn nên giữ tốc độ request hợp lý mỗi IP.

from collections import defaultdict
import time

last_request_time = defaultdict(float)
MIN_INTERVAL_PER_PROXY = 1.5  # giây giữa 2 request cùng endpoint

def fetch_rate_limited(url, **kw):
    proxy = next_proxy()
    elapsed = time.time() - last_request_time[proxy]
    if elapsed < MIN_INTERVAL_PER_PROXY:
        time.sleep(MIN_INTERVAL_PER_PROXY - elapsed)
    last_request_time[proxy] = time.time()

    proxies = {"http": proxy, "https": proxy}
    # ... gọi requests.get như trên

Lý do: dù rotating cho IP mới mỗi request, endpoint gateway vẫn là cùng một host:port. Một số target chặn theo dải IP của gateway, không phải từng IP. Giữ khoảng cách giữa các request qua cùng endpoint giảm rủi ro.

Ví dụ thực tế: scraping danh sách sản phẩm

Giả sử bạn cần scrape 500 URL sản phẩm từ một site e-commerce. Workflow đầy đủ:

import json

urls = [f"https://example-shop.com/product/{i}" for i in range(1, 501)]
results = []
errors = []

for url in urls:
    try:
        resp = fetch_clean(url)
        # parse dữ liệu...
        results.append({"url": url, "status": resp.status_code, "length": len(resp.text)})
    except Exception as e:
        errors.append({"url": url, "error": str(e)})
        print(f"[FAIL] {url}: {e}")

    # Nghỉ giữa các URL để giảm áp lực tổng thể
    time.sleep(0.8)

print(f"Thành công: {len(results)}, Lỗi: {len(errors)}")
with open("errors.json", "w") as f:
    json.dump(errors, f, ensure_ascii=False, indent=2)

Tại sao cần file errors.json

Không bao giờ scrape mà không ghi lại lỗi. File lỗi cho phép bạn:

  • Phân tích pattern: liệu 403 tập trung ở một vùng proxy nhất định?
  • Retry chỉ các URL thất bại trong lần chạy sau, tiết kiệm thời gian.
  • Phát hiện sớm nếu target mới đổi anti-bot.

Dấu hiệu cần đổi strategy

Retry và xoay proxy không giải quyết mọi thứ. Nếu bạn thấy các dấu hiệu sau, cần thay đổi cách tiếp cận:

  • Tỷ lệ 403 > 30%: có thể target chặn theo ASN. Chuyển sang residential proxy từ nhà cung cấp khác hoặc dùng mobile proxy.
  • 429 liên tục dù đã giảm tốc: target có rate limit rất chặt, cần phân tán theo thời gian (queue + scheduler) thay vì chạy song song.
  • CAPTCHA xuất hiện > 10%: fingerprint đang bị phát hiện. Cần dùng headless browser thật, chỉnh timezone, ngôn ngữ, và Accept-Language khớp với vùng IP.
  • Timeout nhiều: endpoint proxy đang quá tải. Giảm concurrency hoặc thêm endpoint dự phòng.

Kết luận

Xử lý 403 và 429 không phải thêm vài dòng try/except. Nó là một chiến thuật có cấu trúc: nhận diện đúng mã, quyết định retry hay bỏ, chọn proxy thay thế, backoff có jitter, và giới hạn tốc độ chủ động. Khi kết hợp với proxy xoay chất lượng từ RoProxy, pipeline scraping của bạn sẽ bền bỉ hơn nhiều và tốn ít IP sạch hơn — thay vì "đốt" proxy rồi mới tìm cách khắc phục.