Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

Bisecting a request body to find why the API returned 403

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 reques

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.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.