跳到正文
DEV Community· Jonas Francisco de Lima Filho·· 3 小時前AI 評分52

如何用二分法找出 API 回傳 403 的原因

Bisecting a request body to find why the API returned 403

AI 導讀

作者以二分法排查 API 更新文章時遇到的 403,將問題縮小至含 shell 命令和 URL 的行內程式碼片段;改寫為普通句子後,文章成功發佈。他建議先查看 4xx 回應的狀態碼、content-type 和前 200 bytes,再以相近大小的無害內容作對照,並在草稿中逐段測試。

正文

I publish articles here from a small script, so the draft exists before I ever open the editor. Last week that script got a 403 back and no article. The same token read the account one second earlier, and the same request went through afterwards with a body that said less. The fix came from splitting the article in half, not from rewriting it.

A 403 with an HTML body is not an authentication problem

The response body started with <!DOCTYPE html> and contained the words "Attention Required!". That is not the shape of this API's errors. Rejected calls come back as JSON, with a message field, a status code and something you can act on. An HTML page in the body means the request stopped in front of the application.

Two more facts from the same minute: reading the article list with the same credentials returned 200 and a full payload, and the failing call had been working for weeks. The token was valid, the route existed, and the decision was made somewhere else.

The habit is cheap to build. On any 4xx, log three things before changing a single character of the input: the status code, the content-type header and the first 200 bytes of the body. When you see HTML, you are no longer debugging your client.

Rule out size with one control payload

The obvious suspect was the size of the request. The article was about 9 KB of markdown, the earlier ones were smaller, and a 403 without a message invites superstition.

So I sent a body of the same order with content I did not care about: repeated paragraphs of plain text, no code, no links, about 8.4 KB, on a draft. It returned an id. The real article failed at roughly 9 KB. That result is weak on its own, because 8.4 and 9 are close enough to sit on either side of a limit.

The bisect settled it. One of the four parts was 2 KB and still failed, so no size threshold explains the pattern. The variable had to be content.

Split by paragraph, and keep the wrapper

The script that did the split is short. It cuts the body into four blocks of whole paragraphs, leaves the markdown intact and writes one payload per part:

import json
import pathlib
import sys


def partes(texto, n=4):
    """Divide em n blocos de paragrafos, sem cortar frase no meio."""
    paras = [p for p in texto.split("\n\n") if p.strip()]
    total = sum(len(p) for p in paras)
    alvo, atual, saida, bloco = total / n, 0, [], []
    for p in paras:
        bloco.append(p)
        atual += len(p)
        if atual >= alvo and len(saida) < n - 1:
            saida.append("\n\n".join(bloco))
            bloco, atual = [], 0
    if bloco:
        saida.append("\n\n".join(bloco))
    return saida


corpo = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
for i, bloco in enumerate(partes(corpo), 1):
    destino = pathlib.Path(f"parte-{i:02d}.json")
    destino.write_text(
        json.dumps({"article": {"body_markdown": bloco}}), encoding="utf-8"
    )
    print(destino.name, len(bloco.encode("utf-8")), "bytes")

Each part goes out as its own update to the same article id. That means the draft ends up holding whatever the last successful part wrote, which is harmless on a draft and is the reason to run this test on a draft instead of on something already published.

Part two failed, parts one, three and four passed, and the failing part was the only one with code in it.

The trigger was one inline command

I split part two again, then the first half of part two, and ended up with a single paragraph. It contained an inline code span: a shell request line, followed by a URL in backticks, in the shape of something a reader could paste and run. Replacing that construction with a plain sentence describing the same request made the whole article post on the first attempt.

What I can state with confidence is the experiment, not the reason. Two bodies differed by that one construction and only one was accepted. I cannot see the rule that scored it, and there is a decent motive behind such a rule, since a body full of paste-ready commands with live URLs is exactly what a spammer would send. The article still needed to describe a real request, and the sentence version did that without shipping the command.

This is where technical prose and content filters disagree. Command blocks are what makes a post useful to the next person. The compromise that worked: keep commands inside fenced blocks, keep a URL out of the same inline span as the command name, and test on a draft where a scrambled body costs nothing.

The wrapper is part of the request

Once I dropped to the raw HTTP route to send parts, each payload has to carry the object explicitly:

{"article": {"body_markdown": "..."}}

Sending a bare object returns 422 with "article param must be a JSON object". The integration tool I normally use builds that wrapper itself, and its schema output does not show it, which is why the requirement only appears when you bypass the tool. Two layers, and forgetting the outer one produces an error that reads like a problem with your data.

The field the tool never sends

The tool that updates an article accepts nine keys. A tenth, ai_disclosure_level, is refused with Unknown key. The route accepts it, with four values: not_disclosed, no_ai, some_ai and fully_autonomous.

That matters because the platform asks authors to disclose machine involvement, and a tool schema that cannot express the field makes compliance look impossible. It is not. One PUT to the article with that single field is enough.

The check worth keeping: a 200 does not prove the field was stored. Read the article back and confirm two values in the response, ai_disclosure_level and ai_disclosure_label. On the most recent post here the pair came back as some_ai and "AI-assisted". A response that shows not_disclosed means the call succeeded and wrote nothing, which is the failure mode to watch for, because the status code looks identical.

The checklist I keep now

  • On a 4xx, read the content type and the first 200 bytes before touching the input.
  • Send a control payload of similar size with content you know is harmless, before rewriting anything.
  • Bisect by paragraph, with the wrapper on every part, on a draft.
  • Read back every field you set. The status code confirms transmission, not storage.
  • There is no delete for articles on this API, so a draft created by mistake stays on the account. Reuse its id with an update instead of creating a second one.

None of this is specific to one platform. Any proxy that inspects request bodies can reject a payload for what it says, and the diagnosis repeats: exclude the boring variables first, then halve the input until the failing piece is small enough to read.

Um recado curto para quem escreve em português: o método serve igual, e a parte que mais economiza tempo é ler o status e o tipo de conteúdo antes de mexer no texto. Se você já perdeu uma tarde com um erro que parecia autenticação e não era, conta nos comentários o que ele era.

來源:DEV Community · dev.to