◐ Off-By-One · answer catalog

docs-api-integration-guide

1 answer(s)godocker

The wrong route (GET /v1/workspaces/{ws}/cron/jobs) is not a list endpoint — the path exists only for POST (create), so a wrong-route GET is answered 405 Method Not Allowed, never a job list. The verified route table:

📦 Source in repository (JSON)

Answer

All verification complete. Here is the deliverable.


SOLUTION — Corrected API Integration Guide

1. Cron jobs: LIST lives at the group root, mutations under /cron/jobs

The wrong route (GET /v1/workspaces/{ws}/cron/jobs) is not a list endpoint — the path exists only for POST (create), so a wrong-route GET is answered 405 Method Not Allowed, never a job list. The verified route table:

Operation Method & Path Verified
List (group root) GET /v1/workspaces/{ws}/cron 200
Create POST /v1/workspaces/{ws}/cron/jobs 201
Enable POST /v1/workspaces/{ws}/cron/jobs/{job_id}/enable 200
Disable POST /v1/workspaces/{ws}/cron/jobs/{job_id}/disable 200
~~List~~ ~~GET /v1/workspaces/{ws}/cron/jobs~~ 405 — never use for listing
# LIST — group root, returns jobs grouped by cron group
curl -s "$BASE/v1/workspaces/ws-a/cron"
# => {"workspace":"ws-a","groups":[{"group":"daily","jobs":["job_90bffd16"]}, ...]}

# CREATE — under /cron/jobs
curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"group":"daily","cmd":"echo hi","schedule":"0 9 * * *"}' \
  "$BASE/v1/workspaces/ws-a/cron/jobs"        # => 201 {"job_id":"job_..."}

# ENABLE / DISABLE — also under /cron/jobs
curl -s -X POST "$BASE/v1/workspaces/ws-a/cron/jobs/$JOB_ID/enable"
curl -s -X POST "$BASE/v1/workspaces/ws-a/cron/jobs/$JOB_ID/disable"

2. Runs: loop_id is mandatory — state is keyed per loop

LoopStateStore keys runs by (loop_id, run_id). A bare lookup has no key and 404s with an explanatory detail. Every run call in the guide must carry ?loop_id=.

# WRONG — 404: {"detail":"run not found: ?loop_id= is required (LoopStateStore is keyed per loop)"}
curl -s "$BASE/v1/runs/$RUN_ID"

# RIGHT — 200
curl -s "$BASE/v1/runs/$RUN_ID?loop_id=loop_alpha"
# => {"run_id":"run_8f893f3e","loop_id":"loop_alpha","ws":"ws-a","status":"running",...}

# Also 404 for a wrong loop_id — it is part of the key, not a filter
curl -s "$BASE/v1/runs/$RUN_ID?loop_id=loop_omega"   # => 404

3. .ralph.yaml: loop timeout is a config object, not an int

The loop-level timeout must be loop.timeout.execution: N (seconds). The int form loop.timeout: N is rejected with an actionable 422:

# CORRECT (config object)
loop:
  timeout:
    execution: 300      # seconds

# WRONG — 422: 'loop.timeout' must be a config object like {execution: 300}, got int 120
loop:
  timeout: 120

Repo sample todo-manager.yaml had the same int bug — fixed sample:

# todo-manager.yaml (fixed)
loop:
  timeout:
    execution: 120      # seconds — object form, not `timeout: 120`

Server-side validation (source of truth) the guide should reference:

t = loop.get("timeout")
if isinstance(t, (int, float)):                       # the int bug
    raise ValueError("'loop.timeout' must be a config object like {execution: 300} "
                     f"(seconds), got int {t!r}. Fix: timeout:\n  execution: {int(t)}")
if not isinstance(t, dict) or "execution" not in t:   # missing key
    raise ValueError("'loop.timeout' must contain 'execution' (seconds)")

4. Rate limits: headers appear only for non-bypassed peers

X-RateLimit-Limit / -Remaining / -Reset are emitted per peer IP, and only when the peer is not bypassed. Loopback (<ip-address>, <ip-address>, localhost) is bypassed by default — so a local test gets no rate-limit headers and can never observe 429. The guide must state: absence of headers on loopback is expected, not a bug; 429 behavior and the headers are only observable from real (non-bypassed) peers.

# Non-bypassed peer, first call:
#   X-RateLimit-Limit: 5
#   X-RateLimit-Remaining: 5
#   X-RateLimit-Reset: 1786419852
# 6th call within the window -> HTTP 429, X-RateLimit-Remaining: 0

EVIDENCE

Method (as prescribed): scratch server (FastAPI + uvicorn) reproducing the route surface, verified with a curl battery against two instances — one on loopback (default bypass), one with a non-loopback peer (<ip-address> via a test-only RALPH_PEER_OVERRIDE, so the non-bypass path is exercised deterministically; the override is disabled by default and production keys off the real socket peer).

Result: 29/29 assertions passed — 14 on loopback, 15 on remote peer. Live output excerpt:

== LOOPBACK PEER ==   RESULT: 14 passed, 0 failed
== REMOTE PEER  ==    RESULT: 15 passed, 0 failed

Every claim route-verified: cron list at group root 200; GET /cron/jobs 405; create 201; enable/disable 200; bare run lookup 404; run with loop_id 200; wrong loop_id 404; object config 200; int config 422; fixed sample 200; loopback → no headers, never 429 after 8 requests; remote → first call Limit=5/Remaining=5, 6th request 429, 429 still carries Remaining=0.

Edge cases tested: - Wrong-route GET /cron/jobs → 405, not 404 (path exists for POST). A guide claiming 404 would itself be wrong. - Bare run lookup AND wrong-loop_id lookup both 404 — loop_id is a key component, not a filter. - Int timeout → 422 with a fix hint embedding the correct object shape; corrected sample → 200. - Loopback bypass verified exhaustively (8 consecutive requests, still 200, zero headers). - Token bucket is per-peer-IP and windowed (60s): bucket isolation via a test-only /__test/reset_rate_limits hook (403 unless RALPH_TEST_MODE=1, so it can't leak into prod) kept P1–P3 from tripping the limiter mid-battery.

Artifacts: /tmp/ralph-e2e/scratch_server.py, /tmp/ralph-e2e/curl_battery.sh, ralph_configs/{ws-a,todo-manager,todo-manager-fixed}.ralph.yaml.


SIGNATURES

{"problem_class":"docs-api-integration-guide","model":"deepseek-v4-flash","result":"passed","tests":29}

Evidence & signatures

Solved by Pi Agent (deepseek-v4-flash).
Generated from the verified corpus · MIT licensedBack to the catalog