method="HEAD" if m == "HEAD" else "GET", # POST -> GET !
Root cause (confirmed in source, not just folklore). The readydedis nginx front does the standard return 301 https://$host$request_uri;. When a client POSTs to http://…, urllib's HTTPRedirectHandler.redirect_request (Python 3.14.4, verified) converts the method to GET on 301/302/303 and drops Content-Type/Content-Length:
# urllib/request.py
return Request(newurl,
method="HEAD" if m == "HEAD" else "GET", # POST -> GET !
headers=newheaders, # content headers stripped
...)
So POST /api/v4/projects/:id/merge_requests silently became GET /api/v4/projects/…/merge_requests, which returns the MR list, and resp["iid"] on a list raised TypeError: list indices must be integers. Only 307/308 preserve the method — nginx's 301 does not. I also verified requests does the same downgrade (see Evidence).
Fix 1 — primary: use https:// directly in all API client scripts so no redirect ever occurs:
import json, urllib.request
GITLAB = "https://gitlab.readydedis.example" # was "http://..." ← the fix
def create_mr(token: str) -> dict:
url = f"{GITLAB}/api/v4/projects/1/merge_requests"
body = json.dumps({"source_branch": "fix/https",
"target_branch": "main",
"title": "fix: use https"}).encode()
req = urllib.request.Request(url, data=body, method="POST", headers={
"PRIVATE-TOKEN": token, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
assert resp.status == 201, resp.status
return json.loads(resp.read()) # dict {"iid": ...} — MR created
Same rule for requests/httpx: point them at the https:// origin (requests.post(..., verify=...) with the real cert); never at the http:// front.
Fix 2 — belt-and-braces: a method-preserving redirect handler in case any client still receives a 301 (works for urllib; for requests you'd have to disable redirect-following and handle it manually, which is why Fix 1 is preferred):
class KeepPostOn301(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
new = super().redirect_request(req, fp, code, msg, headers, newurl)
if new is not None and code == 301:
new.method = req.get_method() # keep POST
new.data = req.data # keep body
new.add_unredirected_header("Content-Type", "application/json")
return new
opener = urllib.request.build_opener(KeepPostOn301)
Fix 3 — ops: never trust a smoke result without checking the port owner. The stale :8083 demo orphan (a bash -lic wrapper spawned by the gateway) kept answering smoke curls with old behavior, masking the fresh binary since T103:
# pre-smoke gate
ss -tlnp | grep ':8083' # -> users:(("python3",pid=112,...))
ps -o pid,ppid,lstart,cmd -p 112 # is this the binary you just built? if not:
kill 112 # kill the orphan
ss -tlnp | grep ':8083' || echo "port free — smoke is trustworthy"
Built a live repro at `~/gitlab-fix-demo/`: a fake nginx front (301 http→https) + TLS API that returns a **dict on POST (201)** and an **MR list on GET**, then ran the buggy vs fixed clients.
```
PASS broken client (http://) reproduces TypeError: reproduced
TypeError: list indices must be integers or slices, not str (POST was downgraded to GET)
PASS fixed client (https://) creates MR: MR created via https directly: {'iid': 1, ...}
PASS alternative method-preserving handler: 301 POST preserved (201)
PASS GET probe tolerates 301: GET probe OK after redirect (final URL https://…)
PASS port owner visible via ss -tlnp: port 39707 listeners: ['96']
RESULT: 5 passed, 0 failed
```
Server-side request log makes the downgrade visible:
```
POST /merge_requests (http-front) ← broken client: POST arrives…
GET /merge_requests (https-api, no body) ← …and arrives as GET → list → TypeError
POST /merge_requests (https-api, JSON body) ← fixed client: POST+body+content-type intact → 201
```
**Edge cases tested:**
- **`requests` library** — same bug: `requests.post(http://…, json=…)` followed the 301, server saw a body-less GET, returned 200 + list. Fix (https directly) covers both libs.
- **GET-only probes** — tolerate the redirect fine (control case), so liveness checks against `http://` are harmless; write-operations must not be.
- **307/308** — method-preserving by spec, but nginx emits 301 here, so they can't be relied on.
- **TLS/cert** — orthogonal to the bug; demo used a self-signed cert (verification disabled) to isolate the method-downgrade, production uses the real cert.
- **Orphan port** — `orphan_demo.sh 8083` spawned a stale listener, `ss -tlnp` showed the owning PID, kill freed the port and the follow-up smoke was trustworthy; port confirmed clean after the demo.{"model": "deepseek-v4-flash", "problem_class": "gitlab-api-http-redirect-post-downgrade", "result": "passed", "tests": 5}