Skip to content
duarteclemente
Go back

I deleted the server that merged my PDFs

A user needed to merge a handful of PDFs into one file before sending them out. On paper that’s a solved problem. The app I’d built for them — a document manager sitting on top of their SharePoint libraries — had a “Combine to PDF” button that had shipped months earlier. It worked. And I wanted it gone.

Not the button. The plumbing behind it.

The feature that worked, and why I still wanted to rip it out

The original combine feature did the sensible enterprise thing: it called out to Azure Functions. One function wrapped Microsoft Graph’s convert-to-PDF so we could throw Word docs and images at it; another did the actual merge server-side. It worked, mostly. But every one of those calls was a thing that could break independently of my app — a function cold-starting, a Graph throttle, a key rotating, a payload limit I’d forgotten about. When a merge failed, I had three moving parts to check before I even reached my own code.

So we made a product call: combine should be PDF-only, and it should run entirely in the browser. No conversion, no server. If you select five PDFs, the app downloads the bytes, stitches them together with pdf-lib, and hands you the result. That decision deleted about 190 lines of backend-call code and took an entire Azure Function off the critical path. The merge itself is maybe fifteen lines: create a document, copy the pages from each source into it, save.

const merged = await PDFDocument.create();
for (const file of pdfs) {
  const src = await PDFDocument.load(bytes, { ignoreEncryption: true });
  const pages = await merged.copyPages(src, src.getPageIndices());
  pages.forEach(p => merged.addPage(p));
}

Images and Office files just aren’t supported anymore, and the modal says so plainly instead of silently trying and failing. I felt good about this. Less surface area, fewer dependencies, a feature I could reason about end to end.

Then I clicked Combine, and every single file came back with the same error:

Invalid response format

Not some files. Not the big ones. Every file, every time.

Where the complexity actually went

Here’s the part I didn’t see coming. Merging in the browser sounds like it removes a dependency, but it really just moves the hard part somewhere else. Now my app has to pull raw PDF bytes down through the SharePoint connector itself, instead of letting a server read them for me. And the Power Apps SDK has an opinion about connector responses.

The SDK looks at each operation’s schema. If the schema declares a typed 200 response, the SDK assumes the body is JSON and runs JSON.parse on it before handing anything to my code. The connector operation I use to read a file, GetFileContent, declares its 200 as { type: 'string', format: 'binary' }. So the SDK takes a stream of raw PDF bytes — which is emphatically not JSON — and tries to parse it as JSON. It fails. It always fails. There is no PDF on earth that parses as JSON. That’s what “Invalid response format” actually is: the SDK choking on binary it was told to treat as text.

The maddening bit is that the schema is technically correct. The response is binary. The SDK just has no path for “typed 200 that isn’t JSON,” so describing the response accurately is the exact thing that breaks it.

The fix is one line, and that’s the point

At runtime, before the first download, I reach into the SDK’s data-sources object and delete the "200" entry from that operation’s response schema:

const info = dataSourcesInfo["sharepointonline"]
  .apis["GetFileContent"].responseInfo;
if (info && "200" in info) delete info["200"];

With no typed 200 to match, the SDK stops trying to be clever and falls back to returning the body as a lossless byte-per-char string — every byte preserved, which I can decode back into a real file. One delete. That’s the whole workaround.

Two things made this hold up, and they’re the difference between a hack and a fix that survives.

First, I don’t edit the generated schema file on disk. Those files get regenerated every time you add a data source, so anything I write there is one CLI command away from being wiped. Instead I mutate the in-memory object the SDK already holds. The SDK re-reads that object on every call, so the patch sticks for the whole session and the generated files stay pristine.

Second — and this is the quieter lesson — finding the real cause let me delete a pile of code I’d written while I was still wrong about it. Before I understood the binary-parsing thing, “Invalid response format” looked intermittent and path-specific, so I’d built an elaborate ladder of fallbacks: try the path one way, then another way, derive alternative IDs, retry on certain messages. Once the actual bug was patched, the first attempt just succeeded, and that whole ladder collapsed down to two lines: try the file path, then fall back to the connector’s own identifier. A misdiagnosis grows code. A real diagnosis lets you throw it away.

What I’ll actually remember

I went into this wanting fewer dependencies, and I got them. But the thing that stuck with me is that removing a dependency doesn’t remove complexity — it relocates it. The Azure Function had been quietly carrying a problem I never had to look at: reading a binary file out of a platform that assumes everything is JSON. The moment I brought that job in-house, the problem was mine.

Which is fine. I’d rather own a one-line patch I understand than a server I have to trust.

If you’re building Power Apps code apps and your downloads are failing with “Invalid response format,” this is why. Delete the typed 200, and go merge your PDFs in peace.


Share this post:

Previous Post
Real React Apps That Run Inside Dataverse
Next Post
Building a 3M-File Document Management System on Power Apps Code Apps: What the SharePoint Connector Doesn't Tell You