Skip to content

fix(facebook): use upload_url + file_url for reel transfer phase#32

Merged
paulocastellano merged 3 commits into
mainfrom
fix/facebook-reels-upload
May 12, 2026
Merged

fix(facebook): use upload_url + file_url for reel transfer phase#32
paulocastellano merged 3 commits into
mainfrom
fix/facebook-reels-upload

Conversation

@paulocastellano
Copy link
Copy Markdown
Contributor

@paulocastellano paulocastellano commented May 12, 2026

Context

Every Facebook reel publish was failing in production. The first attempt at a fix (commit `c379c4c` — hosted-file flow) failed in local testing too, this time with:

```
production.ERROR: Facebook reel upload transfer failed
{
"debug_info": {
"type": "ParameterValidationError",
"message": "HeaderValuePredicate: Header Offset not convertable to unsigned long"
}
}
```

So rupload.facebook.com requires the `Offset` and `file_size` headers even when using the hosted-file (`file_url`) path that Meta's docs document without them. To work around the docs/behavior gap, this PR switches to the well-documented local-file flow.

Root cause (full picture)

The Reels publishing API is a 3-step flow. We had step 2 implemented twice now but both wrong:

Phase Endpoint What we did What's needed
1. start `POST graph.../{page_id}/video_reels` (`upload_phase=start`) ✓ correct Returns `video_id` + `upload_url`
2. transfer (orig) `POST graph.../{video_id}` with `video_file_chunk` Made-up param, FB silently no-op'd → finish saw "Video Upload Is Missing" Must POST to `upload_url`
2. transfer (1st attempt) `POST upload_url` with `file_url` JSON body Rejected: "Header Offset not convertable" Headers also required
2. transfer (this PR) `POST upload_url` with raw bytes + `Offset`/`file_size` headers ✓ matches Meta's local-file docs
3. finish `POST graph.../{page_id}/video_reels` (`upload_phase=finish`, …) ✓ correct

What this PR does

  1. Capture `upload_url` from the start response (was being ignored).
  2. Download the media to a temp file (`tempnam(sys_get_temp_dir(), 'fb_reel_')`) via `Http::withOptions(['sink' => …])` — same pattern `XPublisher` already uses for media downloads.
  3. POST the bytes to `upload_url` with:
    • `Authorization: OAuth {token}`
    • `Offset: 0`
    • `file_size: {filesize}`
    • Body: raw bytes
    • Content-Type: media's mime_type
  4. Cleanup the temp file in a `finally` block.
  5. Bail early with `handleApiError` if start doesn't return `video_id` + `upload_url` so we don't silently no-op like the original code did.

Tests

  • `php artisan test --compact --parallel` — 1504 passed, 2 skipped, 0 failed.
  • `can publish reel` updated: fakes the media-download GET and the rupload POST. Asserts the transfer call has `Offset: 0`, correct `file_size`, and `Authorization: OAuth …` headers.
  • `fails reel publish when start does not return upload_url` covers the bail-out path.
  • `cleans up temp files after reel upload` (pre-existing) now actually exercises a real cleanup since we create temp files again.
  • Manual production verification after deploy.

Production was failing every Facebook reel publish with the cryptic
'Video Upload Is Missing' / error_subcode 1363130. Root cause: our
'transfer' phase was making up its own API contract that doesn't exist.

The Reels publishing API (per Meta docs) is a 3-step flow:

  1. start → POST /{page_id}/video_reels {upload_phase=start}
             returns {video_id, upload_url}
  2. transfer → POST {upload_url} on rupload.facebook.com
                Header: Authorization: OAuth {token}
                Body (JSON): {"file_url": "https://..."}
  3. finish → POST /{page_id}/video_reels {upload_phase=finish, ...}

Our code was doing:

  2 (broken) → POST /{video_id} on graph endpoint
               Body: {video_file_chunk: '<some URL>', access_token: ...}

`video_file_chunk` is not a real parameter; the graph endpoint accepted
the request (returning 200) but nothing was actually uploaded, so the
finish phase reported 'Video was not uploaded'.

Changes:
- Capture upload_url from the start response and use it for transfer.
- POST to that upload_url with file_url in JSON body and
  'Authorization: OAuth ...' header (the format docs.facebook.com
  documents for the hosted-file flow).
- Bail with handleApiError if the start response is missing video_id
  or upload_url so we don't silently no-op like before.

Tests:
- Existing 'can publish reel' test updated to include upload_url in the
  start mock and assert the transfer call actually POSTs to rupload
  with file_url + OAuth header (would have caught this bug before
  shipping).
- New 'fails reel publish when start does not return upload_url' test
  for the safety bail-out.
- Pre-existing 'cleans up temp files after reel upload' test updated
  to match the new mock pattern.

Full suite green: 1504 passed, 2 skipped, 0 failed.
…_size headers

Previous attempt at the hosted-file flow (`file_url` in JSON body) hit
the rupload.facebook.com validator with HTTP 400:

  'HeaderValuePredicate: Header Offset not convertable to unsigned long'

Facebook's rupload endpoint requires the `Offset` and `file_size` headers
regardless of whether the upload is local or hosted-file. The docs
describe the hosted-file path without them, but in practice rupload
rejects requests that lack them.

Switching to the well-documented local-file flow:
- Download the media to a temp file via Http sink (already a pattern
  used in XPublisher for media downloads).
- POST raw bytes to upload_url with three headers:
  - Authorization: OAuth {token}
  - Offset: 0
  - file_size: {actual bytes}
- Use mime_type from the media item as the Content-Type.
- Always cleanup the temp file in a finally block.

Tests updated to fake the media-download GET (returning bytes that the
publisher then re-uploads) and to assert on the Offset/file_size/
Authorization headers being present and correct. The existing
"cleans up temp files after reel upload" test now actually exercises
its assertion since this code path creates temp files again.

Full suite: 1504 passed, 2 skipped, 0 failed.
Five small fixes scoped to the publishReel method:

1. Replace generic \Exception on media download failure with a typed
   FacebookPublishException(ServerError). The generic exception was
   landing in the \Throwable catch in PublishToSocialPlatform with
   category 'unknown', defeating the whole point of the social
   exception hierarchy.

2. Replace handleApiError($startResponse) with a direct
   FacebookPublishException throw when video_id/upload_url are missing.
   The previous code passed a successful HTTP response into a method
   built for error responses — fromApiResponse would fall into the
   default arm and surface 'An unknown Facebook error occurred.'
   ironically reintroducing the same bad UX we just spent the day
   fixing.

3. Drop the four redundant Log::error calls before handleApiError.
   FacebookPublishException::fromApiResponse already pulls the FB
   error code/subcode/message into platformErrorCode + userMessage,
   and the downstream catch in PublishToSocialPlatform::handle logs
   the exception anyway (Nightwatch picks that up). Same pattern as
   the X cleanup in PR #29.

4. Stream the upload body via fopen() resource instead of
   file_get_contents(). Eliminates loading the whole video into memory
   for large reels.

5. Replace \@Unlink with unlink + Log::warning. Surfaces temp-file
   cleanup failures instead of silently leaking files.

Tests:
- Strengthened the missing-upload_url test to assert the exception
  message and class.
- Added a typed-exception test for the media-download failure path
  (would have caught the regression where we used a generic
  \Exception).
- Full suite green: 1505 passed, 2 skipped, 0 failed.
@paulocastellano paulocastellano merged commit 154a202 into main May 12, 2026
2 checks passed
@paulocastellano paulocastellano deleted the fix/facebook-reels-upload branch May 12, 2026 23:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant