↓ Chuyển đến nội dung chính

Cloudflare Early Hints HTTP 103

Lỗi 504 Cloudflare không phải sự cố thật: Early Hints và HTTP 103

Hàng nghìn lỗi 504 trên Cloudflare, nhưng không lỗi nào đến tay người dùng thật: tất cả đều là cache miss của Early Hints. Bài viết giải thích HTTP 103 hoạt động thế nào, cách phân biệt 504 giả với 504 thật, và header đã giải quyết vấn đề.

Lỗi 504 Cloudflare không phải sự cố thật: Early Hints và HTTP 103

Bảng điều khiển của Cloudflare báo một phần tư số request của tôi là 504. Không một lỗi nào trong số đó đến tay người dùng: chúng là cache miss của tính năng Early Hints trên Cloudflare, được ghi thành 504 với ClientRequestSource = earlyHintsCache và OriginResponseStatus = 0. Bài này nói cách phân biệt chúng với 504 thật, và một header duy nhất biến những lần miss đó thành hit.

Tôi gặp chúng lần đầu vào tháng 9, khi bảng điều khiển Cloudflare báo 22% request bị lỗi trong khi mọi health check vẫn xanh; tôi tắt Early Hints và chuyển sang việc khác. Đến tháng 11, cũng zone đó lại có thêm 2.600 lỗi 504 trong một ngày, Early Hints lại đang bật, và lần này tôi muốn biết chính xác chúng là gì, cũng như cách đúng là tắt tính năng thêm lần nữa hay làm cho nó thực sự làm đúng việc. Zone này đứng trước một origin trên Fly.io, phục vụ một Django API, một web app viết bằng Expo và một trang marketing dựng bằng Hugo.

Câu trả lời ngắn
#

  • Những lỗi 504 này được ghi bởi bộ cache Early Hints của Cloudflare, không phải bởi khách truy cập. Trong log của Cloudflare, chúng có ClientRequestSource = earlyHintsCache.
  • Ở đây, 504 nghĩa là cache miss: Cloudflare không có gợi ý nào được lưu cho URL đó. Một lần hit được ghi là 2xx.
  • Chúng không bao giờ chạm tới origin của bạn. OriginResponseStatus là 0.
  • Early Hints chạy bằng response header HTTP Link. Các thẻ <link rel="preload"> trong HTML không cung cấp dữ liệu cho nó.
  • Gửi header đó đi, và trên những trang có thứ cần preload, các lần miss sẽ chuyển thành hit và bắt đầu có phản hồi 103 Early Hints thật.

Có thêm ba điều đáng biết ngay từ đầu. Chỉ gợi ý CSS, script và font quan trọng cho việc render, và giữ crossorigin cho font. Kiểm tra bằng curl -sv --http2: bạn sẽ thấy 103 trước 200. Và dù quyết định thế nào, hãy để earlyHintsCache ra ngoài hệ thống cảnh báo; nếu bạn sẽ không bao giờ gửi header, hãy tắt Early Hints thay vì trả giá bằng tiếng ồn trong log.

Cách phân biệt 504 Cloudflare giả với 504 thật
#

Nếu bạn dùng Logpush hoặc Instant Logs, hãy xem hai trường trước tiên. ClientRequestSource là eyeball với request của khách truy cập thật, và là earlyHintsCache với các lượt tra cứu Early Hints. OriginResponseStatus bằng 0 nghĩa là origin chưa từng được liên hệ.

Trong GraphQL Analytics API, sự phân tách đó chính là dimension requestSource. Đây là truy vấn tôi luôn chạy trước khi tin vào bất kỳ đợt tăng vọt 5xx nào:

query ($zone: String!, $since: Time!) {
  viewer {
    zones(filter: { zoneTag: $zone }) {
      httpRequestsAdaptiveGroups(
        limit: 20
        filter: { datetime_geq: $since, edgeResponseStatus: 504 }
        orderBy: [count_DESC]
      ) {
        count
        dimensions { requestSource userAgent clientRequestPath }
      }
    }
  }
}

Nhóm theo User-Agent cũng cho ra cùng một câu chuyện. Vào tháng 9, các lượt tra cứu Early Hints hiện lên dưới dạng bastion early hints và nginx-ssl early hints. Đến tháng 11 thì là fl2 early hints và nginx-ssl early hints (FL2 là thế hệ proxy mới của Cloudflare). Trong 24 giờ, toàn bộ 2.631 lỗi 504 của zone đều đến từ những User-Agent này. Không có lỗi nào đến từ trình duyệt, ứng dụng, curl hay crawler của công cụ tìm kiếm.

Một lỗi 504 thật mà khách truy cập nhìn thấy thì hầu như luôn đến từ phía bạn, sau Cloudflare: một proxy hay load balancer đứng trước ứng dụng đã chờ quá lâu và bỏ cuộc. Đó có thể là nginx, một load balancer trên cloud, hoặc proxy của nền tảng đang chờ một máy đang ngủ khởi động. Các timeout của chính Cloudflare dùng mã khác: 522 nghĩa là không kết nối được tới origin, còn 524 nghĩa là đã kết nối nhưng không nhận được phản hồi trong vòng 125 giây (mặc định trên các gói dưới Enterprise).

Vậy nếu bạn tìm đến đây vì muốn tăng timeout của Cloudflare do lỗi 504: timeout đứng sau một lỗi 504 gần như không bao giờ là của Cloudflare. Hãy kiểm tra proxy_read_timeout trong nginx, idle timeout của load balancer, hoặc thời gian nền tảng của bạn cần để đánh thức một máy. Một manh mối hữu ích: nếu request thất bại ở một con số tròn thấp hơn hẳn 125 giây, như 30 hay 60, thì đó là giới hạn của proxy của bạn, không phải của Cloudflare. (Chỉ gói Enterprise mới nâng được giới hạn 125 giây của Cloudflare, tối đa 6.000 giây, và chính Cloudflare cũng coi đó là giải pháp tạm thời.)

Bản chất thật của những lỗi 504 này
#

Trên một zone đã bật Early Hints, mỗi request tới một trang HTML hoặc một URL không có phần mở rộng (trên một zone nặng về API thì phần lớn lời gọi API đều thuộc diện này) đều kèm thêm một lượt tra cứu nội bộ: Cloudflare kiểm tra cache của chính nó xem có gợi ý nào để gửi không. Lượt tra cứu đó được ghi thành một dòng riêng, với ClientRequestSource = earlyHintsCache. Nó không phải request của người dùng cuối, và không bao giờ được gửi tới origin của bạn.

Mã trạng thái trên dòng đó là kết quả tra cứu cache, không phải lỗi:

  • 2xx là hit. Cloudflare có lưu header Link cho URL đó và đã gửi chúng tới trình duyệt dưới dạng 103. Trên zone của tôi, hit xuất hiện dưới cả hai mã 200 và 204.
  • 504 là miss. Không có gì được lưu, nên không có gì để gửi sớm.

Nếu origin không bao giờ gửi header mà Early Hints dựa vào, mọi lượt tra cứu đều miss và mọi request đều ghi một dòng 504. Trong ba giờ trước khi tôi thay đổi bất cứ điều gì, zone ghi nhận 173 lượt tra cứu Early Hints và cả 173 đều là miss. Kể từ khi được bật, tính năng này chưa từng gửi đi một gợi ý nào.

Một điều chỉnh cho bài viết tháng 9 của tôi: khi đó tôi mô tả chúng là các probe “không nhận được phản hồi từ origin trên Fly”. Điều đó không hoàn toàn đúng. Chúng hoàn toàn không đi tới origin. Mã 504 ghi lại một lần cache miss, không phải một lần timeout.

Cloudflare có nói ngắn gọn về chuyện này trong tài liệu Early Hints: một lượt tra cứu Early Hints được ghi với ClientRequestSource: earlyHintsCache là “expected and benign” (nằm trong dự kiến và vô hại), 200 là cache hit còn 504 là cache miss, và trong Log Explorer bạn lọc chúng ra bằng ClientRequestSource != "earlyHintsCache". Cache Analytics vốn đã bỏ chúng ra ngoài. Còn biểu đồ lưu lượng HTTP làm tôi hoảng thì không.

Lỗi 504 giả đã khiến tôi nâng cấp máy chủ
#

Đây là lý do việc tách bạch quan trọng. Ngày 3 tháng 11, một ngày trước khi tôi bắt tay vào bài này, Cloudflare ghi nhận 3.292 lỗi 504 trên khoảng 13.300 request của khách truy cập: tỷ lệ lỗi bề ngoài là 25%. API chạy trên máy shared-cpu-1x của Fly.io, loại máy hoạt động theo burst credit: nó chỉ được bảo đảm một phần nhỏ của CPU và phải tiêu credit để chạy nhanh hơn. Một bài kiểm thử tải hồi tháng 8 cho thấy khi tải kéo dài, máy bị bóp xuống khoảng 20 đến 30 request mỗi giây. “Một phần tư số request bị timeout vào giờ cao điểm” trông đúng y như kiểu bóp CPU đó, nên tôi chuyển API sang shared-cpu-2x.

Nhưng tất cả những lỗi 504 đó đều là các lần miss của cache Early Hints. Khi tách theo requestSource và xem lại log của chính origin:

  • Mọi lỗi 5xx của Cloudflare từ 28/10 đến 4/11 đều là lượt tra cứu earlyHintsCache, trừ khoảng một lỗi 525 mỗi ngày (lỗi 525 tôi lần ra đến từ một trình quét bảo mật).
  • Log truy cập của origin từ 24/10 đến 4/11 không có lỗi 5xx nào, và chỉ có một request chậm hơn 10 giây.
  • Số liệu của Fly cho thấy lúc cao nhất cũng chỉ khoảng 7 request mỗi giây, và máy chưa bao giờ dùng quá một phần năm của một lõi CPU. Nó chưa bao giờ tiến gần mức trần bị bóp.

Máy lớn hơn chẳng sửa được gì, vì vốn không có gì hỏng. API đã quay lại shared-cpu-1x.

Đừng ra quyết định về năng lực xử lý dựa trên một biểu đồ 5xx chưa được tách theo request source. Hãy đối chiếu cả với log của chính origin: nếu Cloudflare báo một phần tư số request thất bại mà máy chủ của bạn không ghi nhận lỗi nào, hãy tìm hiểu Cloudflare đang đếm cái gì trước khi động vào máy chủ.

Cập nhật ngày 5/11/2026: phiên bản đầu của phần này viết rằng tuần đó API thực sự đã sập trên shared-cpu-1x. Thực ra không phải vậy. Các số liệu ở trên là những gì tôi tìm thấy khi kiểm tra lại.

HTTP 103 có phải là lỗi không?
#

Không. Các mã trạng thái trong dải 1xx là phản hồi thông tin: những phản hồi tạm thời mà máy chủ có thể gửi trước phản hồi thật. 103 Early Hints, được định nghĩa trong RFC 8297, mang theo header Link để trình duyệt có thể bắt đầu tải CSS, JavaScript và font quan trọng, hoặc mở sẵn kết nối tới các origin khác, trong khi máy chủ vẫn đang dựng trang. Mã 200 cuối cùng sẽ đến sau trên cùng một request.

Mức hỗ trợ không đồng đều nhưng an toàn. Các trình duyệt dựa trên Chromium (Chrome từ phiên bản 103, và Edge) cùng Firefox bản mới xử lý cả gợi ý preload lẫn preconnect. Safari (từ phiên bản 17) chỉ dùng preconnect. Trình duyệt chỉ xử lý 103 qua HTTP/2 và HTTP/3. Trình duyệt nào không hiểu nó thì đơn giản là chờ 200 như mọi khi, nên 103 là một lợi thế xuất phát sớm cho phần lớn khách truy cập và không ảnh hưởng gì tới số còn lại.

Vì sao của tôi trống trơn: header Link so với <link rel=preload>#

Đây là chỗ tôi mất nhiều thời gian nhất, vì trông nó như thể lẽ ra đã hoạt động rồi.

Early Hints chỉ cache đúng một thứ: response header HTTP Link có quan hệ rel=preload hoặc rel=preconnect. Cloudflare đọc nó từ các phản hồi thông thường của bạn (mã 200, 301 hoặc 302, trên URL HTML hoặc không có phần mở rộng), lưu lại, rồi phát lại cho khách truy cập tiếp theo dưới dạng 103 trước khi phản hồi đầy đủ sẵn sàng.

Các trang của tôi vốn đã có preload, nhưng ở dạng thẻ trong <head> của HTML:

<link rel="preload" href="/_expo/static/css/global-7d77bd.css" as="style">
<link rel="preload" href="/_expo/static/js/web/entry-fd7fa4.js" as="script">
<link rel="preload" href="/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin>

Những thẻ này vẫn có tác dụng: trình duyệt hành động theo chúng. Nhưng trình duyệt chỉ thấy chúng sau khi đã nhận HTML và bắt đầu phân tích, mà đó lại chính là khoảnh khắc Early Hints muốn đi trước. Bản export của Expo và theme Hugo tôi dùng đều tự viết các thẻ này, nhưng không cái nào đặt header. Vậy là tính năng đã bật, preload cũng đã khai báo, nhưng hai thứ chưa bao giờ gặp nhau.

Cách sửa: gửi header từ dữ liệu bạn vốn đã có
#

Cách sạch nhất không phải là tự tay duy trì một danh sách tài nguyên quan trọng, thứ sẽ lỗi thời mỗi khi hash của bundle thay đổi. Mỗi trang đã tự khai báo những gì nó muốn preload, trong chính các thẻ đó. Vậy chỉ cần chép chúng sang header Link khi phục vụ trang. html.parser trong thư viện chuẩn xử lý được cả thuộc tính có và không có dấu ngoặc kép (minifier của Hugo bỏ dấu ngoặc đi), nên không có regex nào để viết sai:

from html.parser import HTMLParser


class PreloadLinks(HTMLParser):
    def __init__(self):
        super().__init__()
        self.hints = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag != "link" or "preload" not in (a.get("rel") or "").split() or not a.get("href"):
            return
        hint = f"<{a['href']}>; rel=preload"
        if a.get("as"):
            hint += f"; as={a['as']}"
        if a.get("as") == "font" or "crossorigin" in a:
            hint += "; crossorigin"
        self.hints.append(hint)


def link_header(html: str, limit: int = 12) -> str | None:
    parser = PreloadLinks()
    parser.feed(html)
    return ", ".join(list(dict.fromkeys(parser.hints))[:limit]) or None

Và gắn nó vào phản hồi HTML trong Django:

response = HttpResponse(html, content_type="text/html")
if (link := link_header(html)) and "Link" not in response:
    response["Link"] = link

Có một chi tiết đã làm tôi vấp, và đáng để nhớ: giữ crossorigin cho font. Một preload font thiếu nó sẽ bị coi là một request khác, font bị tải về hai lần, và trang chậm đi chứ không nhanh hơn.

Bạn không cần tự viết 103. Không có đoạn mã phản hồi tạm thời nào phải thêm vào ASGI, Granian hay uvicorn. Cloudflare dựng 103 từ header của bạn; việc duy nhất của bạn là gửi header đó.

Trên các stack khác
#

Quy tắc ở đâu cũng vậy: đặt header Link lên phản hồi HTML và để Cloudflare lo phần còn lại.

  • nginx, trong location phục vụ HTML:

    location = / {
        add_header Link "</css/app.css>; rel=preload; as=style" always;
        add_header Link "</fonts/inter.woff2>; rel=preload; as=font; crossorigin" always;
    }
  • Laravel dùng Vite đã có sẵn middleware cho việc này, AddLinkHeadersForPreloadedAssets, chuyển các preload của Vite thành header Link. Hai vấn đề từng được báo cáo với nó cũng chính là những cái bẫy ở trên: các gợi ý modulepreload của nó từng được gửi đi mà không có crossorigin, khiến trình duyệt có thể bỏ qua chúng và tải script hai lần, và trên các trang lớn, header từng vượt quá giới hạn header khoảng 8 KB của Apache. Hãy kiểm tra cả hai trên phiên bản bạn đang dùng.

  • Mọi môi trường khác: dùng một header Link phân tách bằng dấu phẩy, hoặc gửi nhiều header Link. Cách nào cũng được.

Chỗ mọi người hay làm quá tay là chọn gợi ý những gì. Chỉ gợi ý CSS quan trọng cho việc render, script đầu vào và một hai font dùng ở màn hình đầu tiên, đừng nhét mọi chunk vào. Danh sách dài khiến trình duyệt đi tải những thứ chưa cần trước cả những thứ đang cần. Tôi giới hạn ở mười hai mục, và đó là vì bản export của Expo đã chọn lọc sẵn danh sách preload.

Nếu bạn không đứng sau Cloudflare, hoặc CDN của bạn không hỗ trợ Early Hints, header Link vẫn giúp được một chút: trình duyệt có thể bắt đầu tải các tài nguyên đó ngay khi header phản hồi tới nơi, trước khi phân tích phần thân. Nhưng sẽ không có 103 nào trừ khi máy chủ hoặc CDN của bạn tự gửi.

Cách kiểm tra xem nó đã hoạt động chưa
#

curl cho bạn thấy 103 trực tiếp. Gửi request bằng HTTP/2 và chỉ giữ lại các dòng trạng thái cùng header Link:

$ curl -sv --http2 -o /dev/null https://example.com/register 2>&1 | grep -E '^< (HTTP|link)'
< HTTP/2 103
< link: </_expo/static/css/reset-f5d396.css>; as=style; rel=preload, …
< HTTP/2 200
< link: </_expo/static/css/reset-f5d396.css>; rel=preload; as=style, …

103 đến trước, rồi mới tới 200. Cloudflare sắp xếp lại thứ tự các thuộc tính trong gợi ý, điều này vô hại. Đừng kỳ vọng mọi request đều nhận được 103. Trong ba lần đầu của tôi, chỉ có hai lần nhận được. Lần truy cập đầu tiên sau khi thay đổi chỉ lưu header lại, và Cloudflare bỏ qua 103 khi header của phản hồi cuối cùng đã sẵn sàng.

Sau đó quay lại truy vấn phân tích ở trên, lọc theo requestSource: "earlyHintsCache", nhóm theo mã trạng thái và đường dẫn. Trên những trang bạn đã sửa, hit sẽ dần thay thế miss.

Điều gì đã thay đổi, và điều gì còn lại
#

Vài phút sau khi header được đưa lên production, zone ghi nhận những lần hit Early Hints đầu tiên trong lịch sử, và curl cho thấy phản hồi 103 thật trên các trang web. Một header giải quyết cùng lúc hai vấn đề: tính năng bắt đầu hoạt động, và một phần các “lỗi 504” biến mất.

Điều còn lại là một JSON API chẳng có gì để preload. Các endpoint API tiếp tục miss và tiếp tục ghi các dòng 504, và trên một zone nặng về API thì phần này vẫn chiếm đa số. Bạn có ba lựa chọn hợp lý. Gửi header trên những trang có lợi và chấp nhận các đường dẫn API tiếp tục ghi miss; tính năng hoạt động, các biểu đồ yên tĩnh hơn nhưng không hoàn toàn im lặng. Loại earlyHintsCache khỏi cảnh báo 5xx và dashboard, dù bạn quyết định gì đi nữa, vì một lượt tra cứu cache nội bộ không bao giờ nên đánh thức ai dậy. Hoặc tắt Early Hints (Speed → Settings → Content Optimization trong dashboard, hoặc thiết lập early_hints của zone qua API) nếu bạn sẽ không bao giờ gửi header Link; một ứng dụng một trang có HTML rất nhỏ có thể không được lợi gì nhiều, và khi tắt thì mọi lượt tra cứu dừng lại, tiếng ồn cũng biến mất theo.

Tôi giữ nó bật. Các trang web giờ đã được xuất phát sớm hơn, các lần miss của API đã bị lọc khỏi mọi thứ có thể kích hoạt cảnh báo, và đó mới là điều lẽ ra tôi phải làm vào tháng 9 thay vì tắt tính năng đi.