I keep a weekly job that copies tasks from ClickUp into my own dashboard. It is a small Python wrapper around a CLI that reaches an integration platform, and it publishes a report with the lists and tasks it found. On October 19 it published 12 tasks from 3 lists, and that number happened to be correct.
The reason I went looking was different. The job went green every week, and green was the only thing I was reading. What follows is what I measured when I stopped trusting the exit code.
Every failure exits 0
The wrapper is the usual twelve lines: run the CLI, check the return code, parse stdout as JSON, return the dict.
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if proc.returncode != 0:
raise RuntimeError(f"exit {proc.returncode}: {proc.stderr[:300]}")
return json.loads(proc.stdout)
That code assumes the process fails when the call fails. In this CLI it does not. I sent a task-tracker workspace ID as a JSON number instead of a string and got this, with exit code 0:
{"successful": false,
"error": "Input validation failed for CLICKUP_GET_SPACES.\nSchema: /home/ubuntu/.composio/tool_definitions/CLICKUP_GET_SPACES.json\n- team_id: Instance type \"number\" is invalid. Expected \"string\"."}
A slug that does not exist behaves the same way: exit 0, "successful": false, "slug": "ToolRouterV2_ToolNotFound". The failure is a field inside the JSON, not a process state.
So the wrapper raises on nothing and hands the error object to the next function as if it were a result. What happens next depends on the caller. Where the caller indexes resp["data"], the run dies with KeyError: 'data', which is a message about my key and not about the call that failed. Where the caller guards with if resp.get("successful"), the run finishes green having read zero of whatever it wanted. Neither outcome is a report about the failed call.
The envelope changes shape when the payload gets big
Same tool family, different day. Listing 100 files from Google Drive returns them inline, under data.files. Listing 200 returns this instead:
{"successful": true, "storedInFile": true, "tokenCount": 17598,
"sizeBytes": 70390,
"outputFilePath": "/home/ubuntu/.hermes/cache/scratch/composio/adhoc_143ac9a280f4/GOOGLEDRIVE_LIST_FILES_OUTPUT_e603b2a0.json"}
The 200 files are real. They are inside that file, together with a nextPageToken, and the response that reached my code has no data key at all. Code written against the small response does resp.get("data", {}).get("files", []) and gets [], with no exception and no log line. The wrapper was not wrong about JSON. It was wrong about which JSON.
I did not chase the exact byte threshold where the platform starts spilling to a file. The code has to handle both shapes, so the threshold changes nothing. The habit that did change something: after every large listing, print the length of the list I just built, and compare it with what the response says it contains.
A conditional on the success flag is a silent skip
This is real code from the sync job, and it looks defensive:
resp = run_composio("CLICKUP_GET_FOLDERLESS_LISTS", {"space_id": SPACE_ID})
if resp.get("successful"):
all_lists.extend(resp["data"].get("lists", []))
resp = run_composio("CLICKUP_GET_FOLDERS", {"space_id": SPACE_ID})
if resp.get("successful"):
for folder in resp["data"].get("folders", []):
all_lists.extend(folder.get("lists", []))
Lists live in two places, so the job asks twice and merges. If the second call fails, the job does not stop. It syncs the folderless lists, writes a report with one list instead of three, marks the run ok, and the dashboard shows a plausible number. Nothing about that page lets me tell a quiet week from a lost API call.
The failure that did leave a record in my dashboard was the opposite kind: exit 7 in 0.004 seconds, with an empty body. The script never started, so a wrapper caught it and published a red entry with the log path. Loud failures get recorded. Quiet ones get published as data.
A read that feeds a report should fail loudly. Skipping is correct only when the missing thing is genuinely optional, and then the code should say why in a comment, because the next person reading that conditional cannot tell the two apart.
The same number arrives as the wrong type
The workspace ID is 90171594651. In the API it is a string, and in my payload it went out as a number, which is why the error above names team_id and both types.
I had written it without quotes because it looks like an ID and IDs are numbers. The schema was on disk the whole time, one command away:
composio execute CLICKUP_GET_SPACES --get-schema
The lesson I keep relearning is to read the schema before the first call, not after the third failure. The related one: when a validation error names the field and the two types it wants, that message is better than anything I will discover by rewriting the payload by guess.
One more quirk of this CLI, since it shaped the wrapper: -d is mandatory even for a tool that takes no arguments, so the payload is -d '{}'. Leaving it out is an argument error, which is one of the few failures that does come back with a non-zero code.
The wrapper, with four checks
Each check raises with the tool slug in the message, so a failure names the call and not just the job:
def run_tool(slug, payload):
proc = subprocess.run(
["composio", "execute", slug, "-d", json.dumps(payload)],
capture_output=True, text=True, timeout=120,
)
if proc.returncode != 0:
raise RuntimeError(f"{slug}: exit {proc.returncode}: {proc.stderr[:300]}")
try:
resp = json.loads(proc.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"{slug}: non-JSON stdout: {proc.stdout[:300]}")
if resp.get("successful") is not True:
raise RuntimeError(f"{slug}: {resp.get('error')}")
if resp.get("storedInFile"):
resp = json.loads(Path(resp["outputFilePath"]).read_text())
if resp.get("successful") is not True:
raise RuntimeError(f"{slug}: {resp.get('error')}")
return resp.get("data") or {}
The shape check belongs to the caller, because only the caller knows what a correct answer looks like:
def items(data, key, slug):
value = data.get(key)
if not isinstance(value, list):
raise RuntimeError(f"{slug}: expected {key!r} to be a list, got {type(value).__name__}")
return value
Two lines there carry most of the value. Resolving storedInFile before anyone reads data keeps the envelope invisible to the rest of the code. Making the shape check raise on a missing key turns "zero items" into a sentence that says which tool and which field were wrong.
Test it with wrong input
A wrapper like this is only proven by the failures it catches, so the two cases I keep are a slug that does not exist and an ID sent with the correct value in the wrong type. Both come back with exit 0 and successful: false. A wrapper that raises on those two is reading the answer and not the process. If both pass against a wrapper that only looks at the exit code, the tests are wrong, not the wrapper.
The third case is a large listing, which is the one people skip because the small fixture works. Point the wrapper at any response big enough to spill to disk and assert that the item count matches what the response reports.
Se você mantém rotina agendada batendo em API de terceiro, o ponto que vale discussão é onde o seu wrapper decide que a resposta é aceitável. Escrevo em português também, então deixa um comentário aqui embaixo que eu respondo.
Top comments (0)