Skip to content

Commit c0e3c03

Browse files
committed
v1.4.0: HTTP 413 auto-bisect for the 15 MB upload cap
LOCOSP rolled out a temporary 15 MB body cap on every wdgwars.pl upload endpoint on 2026-06-05 (workaround for CloudLinux LVE killing the PHP worker mid-buffer on bodies above ~20 MB). The server returns a structured 413 envelope with max_bytes + received instead of a generic 500 maintenance page. Cap is expected to be removed in ~2 weeks after a host migration. This release reacts to that envelope automatically so the standalone CLI path (no --chunk-size, single POST) survives an over-cap upload without operator intervention. The proactive --chunk-size 10000 default in the --schedule path was already comfortably under the cap and is unchanged. Added - HTTP 413 auto-bisect in _upload_chunks. On a payload-too-large response the offending chunk is split in half (row-count, header preserved on both halves) and both halves are pushed back onto the work queue. Recursion bottoms out cleanly when a single-row chunk still 413s (recorded as a failure, other chunks continue). - _halve_chunk(bytes) helper; semantics mirror _split_bytes. - tests/test_413_autosplit.py: 7 tests covering the halver, single-bisect success, double-bisect when first halves still oversize, one-row-still- 413 failure recording, and forward-compat for a future max_bytes bump (no hard-coded 15 MB constant — the client reads it off the envelope). Changed - _upload_chunks reworked from a fixed-list for-loop to a deque so the bisect path can push retries back onto the front of the queue without breaking the iteration counter. 200 / 429 / non-JSON paths byte-for- byte identical to v1.3.0. - --chunk-size help text now describes the auto-bisect safety net. - README error table picks up the 413 row alongside the existing 524.
1 parent 03ce93e commit c0e3c03

4 files changed

Lines changed: 302 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,46 @@ All notable changes to wigle-to-wdgwars are documented here. Format
44
follows [Keep a Changelog](https://keepachangelog.com/) and the
55
project uses [Semantic Versioning](https://semver.org/).
66

7+
## [1.4.0] - 2026-06-05 - 15 MB upload cap (HTTP 413 auto-bisect)
8+
9+
LOCOSP rolled out a temporary 15 MB body cap on every wdgwars.pl upload
10+
endpoint on 2026-06-05. The cap is a workaround for the CloudLinux LVE
11+
on the shared host killing the PHP worker mid-buffer on bodies above
12+
roughly 20 MB. Until the planned host migration lands (~2 weeks), the
13+
server returns a structured 413 envelope with `max_bytes` + `received`
14+
instead of a generic 500.
15+
16+
This release reacts to that envelope automatically. The proactive
17+
`--chunk-size` flag is unchanged and remains the recommended path for
18+
scheduled cron uploads, but standalone CLI runs are now resilient too.
19+
20+
### Added
21+
22+
- HTTP 413 auto-bisect. When a chunk comes back with
23+
`{error: payload-too-large, max_bytes, received}`, the offending
24+
chunk is split in half (row-count, header preserved on both halves)
25+
and both halves are pushed back onto the work queue. Recursion
26+
bottoms out cleanly when a chunk is one row and still 413 (recorded
27+
as a failure, other chunks continue).
28+
- `_halve_chunk(bytes) -> tuple[bytes, bytes] | None` helper. Public
29+
enough for sibling tools (Muninn, Heimdall) to vendor if they want
30+
the same behavior; semantics match `_split_bytes`.
31+
- `tests/test_413_autosplit.py`: 7 tests covering the halver, single-
32+
bisect success, double-bisect when first halves still oversize,
33+
one-row-still-413 failure recording, and forward-compat for a
34+
future `max_bytes` change (no hard-coded 15 MB constant).
35+
36+
### Changed
37+
38+
- `_upload_chunks` reworked from a fixed-list `for` loop to a
39+
`collections.deque` so the bisect path can push retries back onto
40+
the front of the queue without breaking the iteration counter.
41+
Behavior on 200/429/non-JSON paths is byte-for-byte identical to
42+
v1.3.0.
43+
- `--chunk-size --help` text now describes the auto-bisect behavior
44+
so the flag's role is clear: proactive splitting to avoid the
45+
Cloudflare 524 timeout window, with the 413 path as a safety net.
46+
747
## [1.3.0] - 2026-06-03 - Family-parity catch-up
848

949
Closes the flag-surface gaps between wigle-to-wdgwars and its sibling

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,7 @@ server treats it as a valid file.
716716
| 400 | `{"error":"Invalid data format"}` | Most likely you POSTed a CSV to `/api/upload` (no `-csv` suffix). Wrong endpoint, not a malformed file. |
717717
| 401 | `{"error":"Invalid API key"}` | Bad/expired key, or you used `Authorization: Bearer …` instead of `X-API-Key:`. |
718718
| 429 | `{"error":"Another upload is already being processed …","retry_after":N}` | Per-account queue. Wait `retry_after` seconds. |
719+
| 413 | `{"error":"payload-too-large","max_bytes":15728640,"received":N,...}` | Body exceeded the 15 MB hosting cap LOCOSP added 2026-06-05. The client auto-bisects the offending chunk and retries both halves — no flag needed. |
719720
| 524 | (HTML from Cloudflare) | Origin timed out. Chunk smaller. Rows are still ingesting on the origin. |
720721

721722
### WiGLE API (the `--from-wigle` pull side)

tests/test_413_autosplit.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Tests for the HTTP 413 auto-bisect path introduced in v1.4.0.
2+
3+
Background: 2026-06-05 LOCOSP rolled out a temporary 15 MB body cap on every
4+
wdgwars.pl upload endpoint with a structured 413 envelope
5+
(`{error: payload-too-large, max_bytes, received, ...}`). The client now
6+
reacts to that envelope by halving the offending chunk and retrying both
7+
halves; this module exercises that behavior end-to-end without touching the
8+
network.
9+
"""
10+
from __future__ import annotations
11+
12+
import io
13+
import json
14+
import unittest
15+
from unittest import mock
16+
17+
import wigle_to_wdgwars as w2w
18+
19+
20+
HEADER = (
21+
b"WigleWifi-1.6,appRelease=2.74,model=Pixel\n"
22+
b"MAC,SSID,AuthMode,FirstSeen,Channel,RSSI,CurrentLatitude,"
23+
b"CurrentLongitude,AltitudeMeters,AccuracyMeters,Type\n"
24+
)
25+
26+
27+
def _row(mac_suffix: int) -> bytes:
28+
return (
29+
f"aa:bb:cc:00:00:{mac_suffix:02x},Net{mac_suffix},[WPA2],"
30+
f"2026-06-05 10:00:00,6,-65,41.46,-82.18,200,5,WIFI\n"
31+
).encode()
32+
33+
34+
def _csv_with_rows(n: int) -> bytes:
35+
return HEADER + b"".join(_row(i) for i in range(n))
36+
37+
38+
def _413_envelope(received: int, max_bytes: int = 15_728_640) -> str:
39+
return json.dumps(
40+
{
41+
"ok": False,
42+
"error": "payload-too-large",
43+
"http_status": 413,
44+
"max_bytes": max_bytes,
45+
"received": received,
46+
"message": "Your upload is too large for this hosting plan...",
47+
"retry_after": 0,
48+
}
49+
)
50+
51+
52+
def _ok_envelope(imported: int = 1, total: int = 1) -> str:
53+
return json.dumps(
54+
{
55+
"ok": True,
56+
"imported": imported,
57+
"captured": imported,
58+
"updated": 0,
59+
"duplicates": 0,
60+
"no_gps": 0,
61+
"bad_rows": 0,
62+
"total": total,
63+
}
64+
)
65+
66+
67+
class HalveChunkTests(unittest.TestCase):
68+
def test_halve_preserves_header_on_both_halves(self) -> None:
69+
csv = _csv_with_rows(8)
70+
halves = w2w._halve_chunk(csv)
71+
assert halves is not None
72+
left, right = halves
73+
# Both halves carry the 2-line WigleWifi-1.6 + column header.
74+
self.assertTrue(left.startswith(HEADER))
75+
self.assertTrue(right.startswith(HEADER))
76+
# Roughly even split (8 rows → 4+4).
77+
left_rows = [ln for ln in left.decode().splitlines()[2:] if ln]
78+
right_rows = [ln for ln in right.decode().splitlines()[2:] if ln]
79+
self.assertEqual(len(left_rows), 4)
80+
self.assertEqual(len(right_rows), 4)
81+
82+
def test_halve_returns_none_on_one_row(self) -> None:
83+
csv = _csv_with_rows(1)
84+
self.assertIsNone(w2w._halve_chunk(csv))
85+
86+
def test_halve_returns_none_on_empty(self) -> None:
87+
self.assertIsNone(w2w._halve_chunk(HEADER))
88+
89+
90+
class UploadChunks413Tests(unittest.TestCase):
91+
"""End-to-end: _upload_chunks should bisect-and-retry on a 413 envelope."""
92+
93+
def _run_with_responses(self, chunks: list[bytes], responses: list[tuple]) -> tuple[int, list[tuple]]:
94+
"""Drive _upload_chunks with a queued list of (status, body) tuples.
95+
Returns (rc, list_of_post_call_arg_summaries).
96+
"""
97+
post_calls: list[tuple] = []
98+
response_iter = iter(responses)
99+
100+
def fake_post(body, name, key, field):
101+
status, raw = next(response_iter)
102+
post_calls.append((status, len(body)))
103+
return status, raw, 0.01
104+
105+
with mock.patch.object(w2w, "_post_one", side_effect=fake_post), \
106+
mock.patch.object(w2w, "_hwm_record"), \
107+
mock.patch("time.sleep"), \
108+
mock.patch("sys.stderr", new=io.StringIO()), \
109+
mock.patch("sys.stdout", new=io.StringIO()):
110+
rc = w2w._upload_chunks(
111+
chunks, name="t.csv", key="k", field="file",
112+
dry_run=False, cooldown_sec=0.0,
113+
)
114+
return rc, post_calls
115+
116+
def test_413_bisects_and_both_halves_succeed(self) -> None:
117+
csv = _csv_with_rows(8)
118+
responses = [
119+
(413, _413_envelope(received=20_000_000)),
120+
(200, _ok_envelope(imported=4)),
121+
(200, _ok_envelope(imported=4)),
122+
]
123+
rc, calls = self._run_with_responses([csv], responses)
124+
self.assertEqual(rc, 0)
125+
# 1 initial POST + 2 halves
126+
self.assertEqual(len(calls), 3)
127+
self.assertEqual(calls[0][0], 413)
128+
self.assertEqual(calls[1][0], 200)
129+
self.assertEqual(calls[2][0], 200)
130+
# Halves are strictly smaller than the original chunk.
131+
self.assertLess(calls[1][1], calls[0][1])
132+
self.assertLess(calls[2][1], calls[0][1])
133+
134+
def test_413_bisects_twice_when_first_halves_still_oversize(self) -> None:
135+
csv = _csv_with_rows(8)
136+
responses = [
137+
(413, _413_envelope(received=40_000_000)),
138+
(413, _413_envelope(received=20_000_000)),
139+
(200, _ok_envelope(imported=2)),
140+
(200, _ok_envelope(imported=2)),
141+
(200, _ok_envelope(imported=4)),
142+
]
143+
rc, calls = self._run_with_responses([csv], responses)
144+
self.assertEqual(rc, 0)
145+
# Initial + left-half-413 + 2 quarter halves + right half = 5 POSTs.
146+
self.assertEqual(len(calls), 5)
147+
statuses = [s for s, _ in calls]
148+
self.assertEqual(statuses, [413, 413, 200, 200, 200])
149+
150+
def test_413_single_row_chunk_records_failure_and_continues(self) -> None:
151+
"""If a chunk is already one row and still gets 413, record it as a
152+
failure and move on. Other chunks must still process."""
153+
one_row = _csv_with_rows(1)
154+
ok_chunk = _csv_with_rows(2)
155+
responses = [
156+
(413, _413_envelope(received=16_000_000)),
157+
(200, _ok_envelope(imported=2)),
158+
]
159+
rc, calls = self._run_with_responses([one_row, ok_chunk], responses)
160+
# Aggregate ok=False because one payload failed.
161+
self.assertEqual(rc, 1)
162+
self.assertEqual(len(calls), 2)
163+
self.assertEqual(calls[0][0], 413)
164+
self.assertEqual(calls[1][0], 200)
165+
166+
def test_413_max_bytes_not_hard_coded_against_server_value(self) -> None:
167+
"""If LOCOSP later raises the cap to 25 MB, the client must still
168+
react to the same envelope shape — not a baked-in constant.
169+
We exercise this by sending a 413 envelope with max_bytes=25 MB and
170+
confirming the bisect path fires identically."""
171+
csv = _csv_with_rows(8)
172+
responses = [
173+
(413, _413_envelope(received=30_000_000, max_bytes=26_214_400)),
174+
(200, _ok_envelope(imported=4)),
175+
(200, _ok_envelope(imported=4)),
176+
]
177+
rc, calls = self._run_with_responses([csv], responses)
178+
self.assertEqual(rc, 0)
179+
self.assertEqual(len(calls), 3)
180+
181+
182+
if __name__ == "__main__":
183+
unittest.main()

wigle_to_wdgwars.py

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,12 @@
3434
"""
3535
from __future__ import annotations
3636

37-
__version__ = "1.3.0"
37+
__version__ = "1.4.0"
3838
GITHUB_REPO = "HiroAlleyCat/wigle-to-wdgwars"
3939
GITHUB_URL = f"https://github.com/{GITHUB_REPO}"
4040

4141
import argparse
42+
import collections
4243
import gzip
4344
import json
4445
import logging
@@ -790,6 +791,27 @@ def _split_csv(csv_path: Path, chunk_rows: int) -> list[bytes]:
790791
return _split_bytes(_read_csv_bytes(csv_path), chunk_rows)
791792

792793

794+
PAYLOAD_TOO_LARGE_ERROR = "payload-too-large"
795+
796+
797+
def _halve_chunk(chunk_bytes: bytes) -> tuple[bytes, bytes] | None:
798+
"""Bisect a WiGLE CSV chunk into two row-count halves, header preserved
799+
on each. Returns None if the chunk has fewer than 2 data rows (cannot
800+
bisect further). Used to react to LOCOSP's 15 MB upload cap (2026-06-05):
801+
on HTTP 413 the offending chunk is halved and both halves are retried.
802+
"""
803+
raw = chunk_bytes.decode("utf-8").splitlines(keepends=False)
804+
if len(raw) < 4:
805+
return None
806+
h1, h2, *data_rows = raw
807+
mid = len(data_rows) // 2
808+
if mid < 1:
809+
return None
810+
left = (h1 + "\n" + h2 + "\n" + "\n".join(data_rows[:mid]) + "\n").encode("utf-8")
811+
right = (h1 + "\n" + h2 + "\n" + "\n".join(data_rows[mid:]) + "\n").encode("utf-8")
812+
return left, right
813+
814+
793815
def _aggregate(payloads: list[dict]) -> dict:
794816
"""Merge per-chunk response envelopes into one summary."""
795817
keys = ("imported", "captured", "updated", "duplicates", "no_gps", "bad_rows", "merged_samples")
@@ -813,7 +835,14 @@ def _aggregate(payloads: list[dict]) -> dict:
813835

814836
def _upload_chunks(chunks: list[bytes], name: str, key: str, field: str,
815837
dry_run: bool, cooldown_sec: float) -> int:
816-
"""POST pre-split CSV chunks to WDGoWars. Returns shell exit code (0 ok)."""
838+
"""POST pre-split CSV chunks to WDGoWars. Returns shell exit code (0 ok).
839+
840+
Resilient to HTTP 413 from LOCOSP's 15 MB upload cap (2026-06-05): any
841+
chunk that comes back with `{error: payload-too-large, max_bytes, received}`
842+
is bisected and both halves are pushed back onto the work queue. Recursion
843+
bottoms out when a chunk is one row and still 413 (recorded as a failure,
844+
other chunks continue).
845+
"""
817846
total_kb = sum(len(c) for c in chunks) / 1024
818847
print(
819848
f"[wdgwars] POST {ENDPOINT} field={field} file={name} "
@@ -823,18 +852,50 @@ def _upload_chunks(chunks: list[bytes], name: str, key: str, field: str,
823852
if dry_run:
824853
print("[wdgwars] dry-run: not sending", file=sys.stderr)
825854
return 0
855+
queue: collections.deque[bytes] = collections.deque(chunks)
826856
payloads: list[dict] = []
827-
for idx, body in enumerate(chunks, 1):
857+
attempt = 0
858+
splits = 0
859+
while queue:
860+
attempt += 1
861+
body = queue.popleft()
828862
try:
829863
status, raw, dur = _post_one(body, name, key, field)
830864
except urllib.error.URLError as e:
831-
sys.exit(f"[wdgwars] network error on chunk {idx}/{len(chunks)}: {e}")
865+
sys.exit(f"[wdgwars] network error on attempt {attempt}: {e}")
832866
try:
833867
data = json.loads(raw)
834868
except json.JSONDecodeError:
835869
data = {"ok": False, "error": "non-json response", "raw": raw[:300]}
870+
871+
if status == 413 and data.get("error") == PAYLOAD_TOO_LARGE_ERROR:
872+
halves = _halve_chunk(body)
873+
max_b = data.get("max_bytes")
874+
recv = data.get("received")
875+
if halves is None:
876+
print(
877+
f"[wdgwars] attempt {attempt} HTTP 413 with 1 row, cannot "
878+
f"bisect further (max_bytes={max_b} received={recv}). "
879+
f"Recording as failure, continuing.",
880+
file=sys.stderr,
881+
)
882+
payloads.append(data)
883+
continue
884+
left, right = halves
885+
queue.appendleft(right)
886+
queue.appendleft(left)
887+
splits += 1
888+
print(
889+
f"[wdgwars] attempt {attempt} HTTP 413 "
890+
f"(max_bytes={max_b} received={recv}); bisecting and retrying "
891+
f"({len(left) // 1024}+{len(right) // 1024} KB halves)",
892+
file=sys.stderr,
893+
)
894+
time.sleep(cooldown_sec)
895+
continue
896+
836897
print(
837-
f"[wdgwars] chunk {idx}/{len(chunks)} HTTP {status} in {dur:.1f}s "
898+
f"[wdgwars] attempt {attempt} HTTP {status} in {dur:.1f}s "
838899
f"imported={data.get('imported')} dup={data.get('duplicates')} "
839900
f"merged={data.get('merged_samples')} bad={data.get('bad_rows')}",
840901
file=sys.stderr,
@@ -847,9 +908,14 @@ def _upload_chunks(chunks: list[bytes], name: str, key: str, field: str,
847908
print(f"[wdgwars] 429 cooldown, sleeping {wait:.0f}s", file=sys.stderr)
848909
_cooldown_record(wait)
849910
time.sleep(wait)
850-
elif idx < len(chunks):
911+
elif queue:
851912
time.sleep(cooldown_sec)
852-
if len(chunks) == 1:
913+
if splits:
914+
print(
915+
f"[wdgwars] auto-split {splits} chunk(s) on 413 over the run",
916+
file=sys.stderr,
917+
)
918+
if len(payloads) == 1:
853919
print(json.dumps(payloads[0]))
854920
return 0 if payloads[0].get("ok") else 1
855921
agg = _aggregate(payloads)
@@ -1574,8 +1640,11 @@ def main() -> int:
15741640
ap.add_argument("--no-version-check", action="store_true",
15751641
help="skip the daily GitHub release check entirely")
15761642
ap.add_argument("--chunk-size", type=int, default=0,
1577-
help="split CSV into N-row chunks to dodge Cloudflare 524s (0=single POST). "
1578-
"10000 is a safe default for large uploads.")
1643+
help="proactively split CSV into N-row chunks (0=single POST). "
1644+
"10000 is a safe default for large uploads. The tool also "
1645+
"reacts to LOCOSP's 15 MB upload cap (HTTP 413 envelope) "
1646+
"by bisecting any over-cap chunk automatically, so this "
1647+
"flag is mostly belt-and-suspenders.")
15791648
ap.add_argument("--chunk-cooldown", type=float, default=5.0,
15801649
help="seconds to sleep between chunks (default: 5)")
15811650
ap.add_argument("--whoami", action="store_true",

0 commit comments

Comments
 (0)