Python urllib gets 403 'error code: 1010' from api.sume.com: set a UA

Python's default urllib User-Agent got a plain-text HTTP 403 from api.sume.com in my test while other clients passed. Add a User-Agent and parse errors safely.

4 min readSume
All posts

If a urllib.request call to https://api.sume.com returns 403 Forbidden and the body is the plain text error code: 1010, add a User-Agent header. In my test on 2026-10-10 the default Python-urllib agent was refused, and the same request with any custom agent reached the API and got its normal JSON 401.

This is something I measured, not something the docs describe, so treat it as an observation that can change. The Sume errors page documents JSON error bodies with a request_id; the 403 below does not have one.

What I tested

I sent GET /v1/me with a deliberately invalid key, so the expected answer is 401 unauthorized from Sume. Only the User-Agent changed between runs. A blocked client never reaches the key check, so the 403 is not about your key.

GET /v1/me with a bad key, one run per User-Agent, measured 2026-10-10
User-Agent sentHTTP statusReached Sume's key check
Python-urllib/3.14 (the stdlib default)403No
my-app/1.0 (custom, urllib)401Yes
python-requests/2.32.0401Yes
python-httpx/0.27.0401Yes
Go-http-client/1.1401Yes
axios/1.7.0, okhttp/4.12.0, curl/8.7.1401Yes

Why this bites in scripts

Most examples for a REST API in Python use requests or httpx, which send their own agent string and passed in my test. The failure shows up when someone writes a dependency-free script, as in a CI job or a minimal container, with only urllib.request. The unauthenticated GET /v1/health and /v1/catalog calls also returned 403 with the default agent, so a health probe written this way will look like an outage.

The second trap is the error handler. The 403 body is not JSON, so json.load(e) raises a JSONDecodeError that hides the real status. Read the body as text first, then try to parse it, as the sample does.

import json, os, urllib.error, urllib.request

def sume_get(path):
    req = urllib.request.Request(
        "https://api.sume.com" + path,
        headers={"x-api-key": os.environ["SUME_API_KEY"],
                 "User-Agent": "my-service/1.0 (ops@example.com)"})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.load(r)["data"]
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", "replace")
        try:
            err = json.loads(raw)["error"]
        except ValueError:
            raise RuntimeError(f"HTTP {e.code} with a non-JSON body: {raw[:60]!r}") from e
        raise RuntimeError(f"{e.code} {err['code']} request_id={err['request_id']}") from e

print(sume_get("/v1/me")["account"]["api_key"]["prefix"])

What to put in the header

Use a stable string that names your service and a contact, for example my-service/1.0 (ops@example.com). It costs nothing, and it gives anyone reading edge logs a way to find you. Do not impersonate a browser agent to get around a block; if a custom agent is ever refused too, report it to Sume with the time and the source IP.

Remember the other header rule while you are there: send exactly one of x-api-key or Authorization: Bearer. Sending both returns 401 unauthorized with Send only one API key credential., per the Authentication page.

  • Check the body type before parsing; a non-JSON error did not come from the Sume API handler.
  • Log the status, the first 60 characters of the body, and your agent string.
  • Add the header once in a helper, not at every call site.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume