Skip to content

Let the embed-create page wait for its create, and tell the host when the outcome is not known - #300

Merged
Jaggob merged 6 commits into
mainfrom
fix/embed-create-timeout
Sep 26, 2026
Merged

Jaggob merged 6 commits into
mainfrom
fix/embed-create-timeout

Conversation

@Jaggob

@Jaggob Jaggob commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

The embed-create page waits for its create, and tells the host when the outcome is not known. A follow-up to #299, which made the embed page's initialise and recovery wait.

What changes

The create is a write, but it went with fetchJsonWithTimeout()'s default ten seconds. A slow create reported epnc:create-failed with reason: 'network' and "Request timed out." while the server went on and made the pad and the file. A second try then met the name the first one had taken (409), and the host never heard of the pad that did come about.

No time limit. The create now waits, as the embed page's initialise and recovery do since #299. A slow create is not a failed one: the page and the host hear nothing until the server or a proxy answers.

'network' means the outcome is not known. The failure reasons stay the same four, but 'network' now covers every case without an answer from this app:

What came goes along in status. A gateway's or PHP's 5xx counted as 'server' before.

'server' covers three cases:

  • a 4xx, even one whose body broke off: this app refused the create, or a proxy or firewall stopped it before (a 413, say), so nothing was created;
  • a 5xx of this app's own, which it rolls back as far as it can;
  • an answer the page cannot use.

What the host and the page say. After 'network', whatever fetch rejected with, the page shows and the host's message carries a translated sentence: "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again." Before, it was the browser's English ("Failed to fetch") or "Request failed.". An answer without a sentence of its own now reads "Pad creation failed.", translated, which never reached the page before.

The payload of epnc:create-failed also carries the server's code and retryable, null and false without them, as the viewer and the embed page read them since #299. A host can offer a second try when the folder was locked (503 with retryable), and can tell pad_file_changed from a name taken, both 409.

For every client. fetchJsonWithTimeout() takes a 5xx whose body is not JSON for no answer from this app, since this app answers every error in JSON. The viewer and the embed page offer "Try again" after one when they open. When the body breaks off after the status line, the status goes along on the error. isUnanswered() is the one check for the flag.

architecture.md holds the host contract, one point for each reason, and adds that a host which stops waiting on its own must assume the pad may still be created. The rule for what counts as no answer is stated once there too ("Errors of the API"), and api-reference.md holds the server's facts it rests on. The page's docblock and the helper's comment point there instead of repeating them.

Checked in the stack

NC 34.0.3, driven with Playwright. The create page was framed in a Nextcloud page of the same origin that recorded its messages:

  • A create held back for 13 s: nothing at 11 s, then epnc:create-succeeded at 13 s, and the file was there.
  • The create request failed on the network: create-failed, reason: 'network', status: null, with the new sentence, on the page too.
  • Answered by a 504 gateway page or an HTML 500 as from a PHP fatal: the same with status: 504 and 500.
  • Answered by a proxy's 413 page: 'server', 413, "Pad creation failed.".
  • Answered 503 with retryable, as for a locked folder: 'server', 503, retryable: true, and the server's sentence.
  • The embed page, with Etherpad disconnected and with faked failures, behaved as with Offer to try the open again wherever it may work later #299.

Bundles

Built with Node 24 in a copy of the tree with a real node_modules, as for #299; building again gives the committed js/. The fetch-helpers and pad-open-flow chunks get new hashes, and the embed-create, embed and viewer bundles change.

Tests

  • The create waits a minute and still reports create-succeeded, with no failure before.
  • One table of what the host is told and the page shows, and that the page does not redirect, one row for each kind of failure; which failures count as no answer is the helper's test's to hold:
    • 'network': the network failing, the browser stopping the request, a 502 page, a success whose body broke off;
    • 'conflict': a name taken, a file that changed while its pad was set up (with pad_file_changed), a name taken whose body broke off;
    • 'server': a pad type switched off (with its code), a proxy's 413, this app failing with and without a sentence, a locked folder (with retryable).
  • fetchJsonWithTimeout(): a 500 or 524 page is no answer; this app's JSON 500 and a 413 page are. The status goes along when the body breaks off or times out. isUnanswered() takes only true.
  • EmbedController: both embed pages get their sentence for no answer, and the create page its sentence for a failure without one.
  • Mutation check over the rounds: 7, 10, 4, 3, and 14 against the final code and the shortened tests, all caught but one of the 4. That one took any truthy retryable for true, which behaves the same, since the helper sets the flag only to true. Among the faults:
    • the create timing out again;
    • a gateway or PHP page taken for this app's answer, or any page for none;
    • a refusal whose body broke off called 'network';
    • the browser's English, or the create's own fallback lost or left in English;
    • the status dropped or not carried along;
    • any truthy flag taken for no answer;
    • code or retryable not passed on to the host.

Left as it is

  • The page gives no sign while it waits, and the host hears nothing until the server or a proxy answers. A create-pending event was considered and left out: the existing event carries more now, but no host has to handle a new one.
  • A second try after 'network' meets a 409 if the first create went through, and cannot tell that pad from another file of the same name. An idempotent create (a key the retry sends, or opening the existing file of that name) would settle it. That is a change to the create API, and it belongs with the common path for creating pads.
  • Every request has the ten-second limit unless its call site turns it off, so a new write can forget, as this one did. A required choice at the helper would enforce it; with TypeScript it would be a type error.
  • The create page's older English sentences stay as they were: a missing request token, and a success without a usable embed_url, which only a server bug gives.
  • After a 5xx page on opening, the viewer and the embed page say "Nextcloud did not answer. Check your connection and try again.", although the connection is fine and the instance is not. The button does the right thing, and an instance that fails every request shows it elsewhere too. A sentence of its own would be one more to translate, for a broken instance.

Numbers

  • JS tests 333 → 350 (Node 24); duplicate tests became rows, and the stubbed answers come from one tests/js/responses.js. PHP tests unchanged at 1404, lint green.
  • Psalm green on OCP 31.0.9 and 34.0.4, baseline unchanged at 221.

… the outcome is not known

The create is a write, but it went with the default ten-second timeout:
a slow create reported 'network' with "Request timed out." while the
server went on and made the pad and the file, and a second try met the
name it had taken. It now has no client-side time limit, like the
embed page's initialise and recovery; a slow create is not a failed
one.

'network' now means no answer from this app, which is the one case where
the outcome is not known: fetch failed, also while the body streams in,
or a proxy answered in its place, with its 502, 503 or 504 in status.
A gateway's answer counted as 'server' before, which says nothing was
created - true only of this app's own answers, since the server rolls a
failed create back. After 'network' the page and the host's message say,
in a translated sentence, that the pad may have been created anyway and
to look in the folder before trying again. architecture.md says a host
that stops waiting on its own must assume the same.
…server' can promise

A 5xx counted as no answer from this app only as a 502, 503 or 504
without retryable. This app answers every error in JSON, so a 5xx whose
body is not JSON came from somewhere else too: a proxy's 524 after a
slow origin, or PHP dying midway, after the create may have written the
file. It is unanswered now, in every client. A 4xx page still counts as
an answer: a proxy refusing a body too large never reached the create.
When the body breaks off after the status line, the status goes along
on the error.

The embed-create page takes a 4xx as refused even when its body broke
off, and anything else without this app's answer as 'network', always
with the sentence to look in the folder. 'server' no longer promises
that nothing was created, since the rollback is best effort and a
success the page cannot use counts there too. The create's own
fallback sentence reaches the page, the template no longer carries a
third copy of the sentence, and a controller test holds that both
embed pages get theirs. isUnanswered() is the one check for the flag.
…ses under 'server'

epnc:create-failed carried reason, status and message, but not the
server's code or retryable. A locked folder answers 503 with retryable,
and the host could not offer a second try, as the viewer and the embed
page do since #299; nor tell pad_file_changed from a name taken, both
409. The payload carries both now, null and false without them.

'server' was described as this app refusing or failing the create, yet
a proxy's or a firewall's 4xx never reached it. It now reads: refused
with a 4xx, by this app or before it, so nothing was created; failed by
this app with a 5xx of its own, rolled back as far as it can; or an
answer the page cannot use.
"Pad creation failed." reaches the reader since the create passes it as
its fallback, after a proxy's 413 page, say, or a 409 whose body broke
off, but it was English. The page takes it translated from its template,
as its other sentences.
The create's host contract stood in full in failCreate()'s docblock and
in architecture.md, and the two had begun to drift. It is in
architecture.md now, one point for each reason; the docblock names the
four in a line each and points there. The rule for what counts as no
answer stood four times. The server's facts are in api-reference.md,
the clients' rule under "Errors of the API", and the helper's comment
and its test point there.

The create's catch reads as three cases - refused, no answer, this
app's own 5xx - with the unanswered flag read once and without
classifyHttpStatus(). Its tests are one table of what the host is told,
one row for each kind of failure; which failures count as no answer is
the helper's test's to hold. The stubbed answers come from
tests/js/responses.js instead of six copies.
…helpers left

The table of the create's failures lost the old conflict test's check
that the page does not redirect after one. It holds it for every row
now. Where the answer stubs were removed, double blank lines stayed
behind in five test files.
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Wait for embed pad creation and report unknown outcomes

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Removes embed-create timeouts so slow writes complete without false failures.
• Distinguishes unanswered requests from definitive failures and enriches host events.
• Localizes recovery guidance and expands regression coverage and contract documentation.
Diagram

sequenceDiagram
  actor Host
  participant Page as Create Page
  participant Helper as Fetch Helper
  participant Proxy
  participant API as Nextcloud API
  Host->>Page: Load create
  Page->>Helper: POST without timeout
  Helper->>Proxy: Create request
  Proxy->>API: Forward request
  alt App JSON response
    API-->>Proxy: JSON status
    Proxy-->>Helper: API response
    Helper-->>Page: Success or failure
    Page-->>Host: Known outcome
  else No app answer
    Proxy--x Helper: Page or disconnect
    Helper-->>Page: Unanswered error
    Page-->>Host: Outcome unknown
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Idempotent create API
  • ➕ Retries could recover the original successful result without filename conflicts.
  • ➕ Hosts would not need to treat unanswered creates as permanently ambiguous.
  • ➖ Requires a create API contract and persistence change.
  • ➖ Needs coordinated adoption across all pad-creation clients.
  • ➖ Exceeds the scope of this client-side regression fix.
2. Use a longer finite timeout
  • ➕ Prevents the iframe from waiting indefinitely.
  • ➕ Requires only a small call-site configuration change.
  • ➖ Any finite limit can still misreport a slow successful write.
  • ➖ Retried creates can still collide with files produced after timeout.
  • ➖ Does not resolve ambiguous proxy or streaming failures.

Recommendation: Keep the PR's unlimited client wait and explicit unknown-outcome classification as the safest compatible fix. Pursue idempotency separately as the durable solution for safe retries; a longer timeout only postpones the same race.

Files changed (37) +241 / -156

Bug fix (14) +78 / -44
etherpad_nextcloud-embed-create-main.mjsRebuild embed-create bundle with safe write handling +1/-1

Rebuild embed-create bundle with safe write handling

• Generated bundle now waits indefinitely for creation, classifies unanswered outcomes, uses localized fallbacks, and sends enriched failure events.

js/etherpad_nextcloud-embed-create-main.mjs

etherpad_nextcloud-embed-create-main.mjs.mapRefresh embed-create bundle source map +1/-1

Refresh embed-create bundle source map

• Updates generated source mappings to reflect the revised create timeout and error-classification flow.

js/etherpad_nextcloud-embed-create-main.mjs.map

etherpad_nextcloud-embed-main.mjsRebuild embed bundle with shared unanswered detection +1/-1

Rebuild embed bundle with shared unanswered detection

• Generated embed bundle consumes the new fetch-helper chunk and uses the centralized unanswered predicate during recovery.

js/etherpad_nextcloud-embed-main.mjs

etherpad_nextcloud-embed-main.mjs.mapRefresh embed bundle source map +1/-1

Refresh embed bundle source map

• Updates generated mappings for the shared unanswered-error helper integration.

js/etherpad_nextcloud-embed-main.mjs.map

etherpad_nextcloud-viewer-init.mjsRebuild viewer bundle with shared unanswered detection +1/-1

Rebuild viewer bundle with shared unanswered detection

• Generated viewer bundle uses the new helper and rebuilt pad-open-flow chunk when handling recovery failures.

js/etherpad_nextcloud-viewer-init.mjs

etherpad_nextcloud-viewer-init.mjs.mapRefresh viewer bundle source map +1/-1

Refresh viewer bundle source map

• Updates generated viewer mappings for centralized unanswered-error checks and renamed chunks.

js/etherpad_nextcloud-viewer-init.mjs.map

fetch-helpers-BUxbvlK6.chunk.mjsAdd rebuilt fetch-helper chunk +2/-0

Add rebuilt fetch-helper chunk

• Introduces the hashed production chunk containing broader non-app 5xx detection, status preservation, and 'isUnanswered()'.

js/fetch-helpers-BUxbvlK6.chunk.mjs

fetch-helpers-BUxbvlK6.chunk.mjs.mapAdd fetch-helper chunk source map +1/-0

Add fetch-helper chunk source map

• Adds source mappings for the rebuilt fetch helper and its unanswered-response logic.

js/fetch-helpers-BUxbvlK6.chunk.mjs.map

pad-open-flow-By_FzSKk.chunk.mjsRebuild pad-open flow against the new fetch helper +4/-4

Rebuild pad-open flow against the new fetch helper

• Updates the generated chunk dependency and routes retry decisions through the shared unanswered predicate.

js/pad-open-flow-By_FzSKk.chunk.mjs

pad-open-flow-By_FzSKk.chunk.mjs.mapRefresh pad-open-flow chunk source map +1/-1

Refresh pad-open-flow chunk source map

• Updates generated mappings for the centralized unanswered predicate and renamed fetch chunk.

js/pad-open-flow-By_FzSKk.chunk.mjs.map

EmbedController.phpProvide localized embed-create failure messages +2/-0

Provide localized embed-create failure messages

• Passes translated unknown-outcome and generic creation-failure sentences into the create template.

lib/Controller/EmbedController.php

embed-create-main.jsWait for creation and report precise outcomes +32/-22

Wait for creation and report precise outcomes

• Disables the timeout for the create write and distinguishes conflicts, definitive server failures, and unanswered requests. Failure events now include normalized messages, status, server code, and retryability.

src/embed-create-main.js

fetch-helpers.jsClassify unanswered API requests consistently +27/-10

Classify unanswered API requests consistently

• Marks non-JSON 5xx and unsupported gateway responses as unanswered while preserving any received status through streaming failures and timeouts. Adds a strict 'isUnanswered()' helper used by clients.

src/lib/fetch-helpers.js

embed-create.phpExpose localized failure text to embed-create +3/-1

Expose localized failure text to embed-create

• Adds template data attributes for the unknown-outcome warning and generic create failure.

templates/embed-create.php

Refactor (3) +6 / -5
embed-main.jsUse centralized unanswered detection in embed recovery +2/-2

Use centralized unanswered detection in embed recovery

• Replaces direct inspection of the 'unanswered' property with the shared strict predicate.

src/embed-main.js

pad-open-flow.jsCentralize unanswered checks for open retries +2/-1

Centralize unanswered checks for open retries

• Uses 'isUnanswered()' when deciding whether a failed open can be retried.

src/lib/pad-open-flow.js

viewer-main.jsUse centralized unanswered detection in viewer recovery +2/-2

Use centralized unanswered detection in viewer recovery

• Replaces direct flag inspection with the shared strict predicate after snapshot recovery failures.

src/viewer-main.js

Tests (8) +129 / -103
embed-create-main.test.jsCover slow creates and host failure payloads +64/-51

Cover slow creates and host failure payloads

• Adds table-driven coverage for network ambiguity, broken bodies, conflicts, proxy refusals, application failures, codes, and retryability. Verifies a create still succeeds after waiting beyond the former timeout.

tests/js/embed-create-main.test.js

embed-main.test.jsReuse shared response fixtures in embed tests +1/-10

Reuse shared response fixtures in embed tests

• Replaces local fetch-response builders with centralized JSON and error response fixtures.

tests/js/embed-main.test.js

api-client.test.jsReuse shared API response fixtures +2/-9

Reuse shared API response fixtures

• Moves API-client response stubs to the common fixture module, including non-JSON page responses.

tests/js/lib/api-client.test.js

fetch-helpers.test.jsExpand unanswered-response classification tests +25/-21

Expand unanswered-response classification tests

• Covers non-JSON 5xx pages, app-generated JSON errors, proxy refusals, status preservation, and strict 'isUnanswered()' behavior. Existing fixtures are consolidated into the shared response module.

tests/js/lib/fetch-helpers.test.js

pad-content.test.jsReuse shared pad-content response fixtures +1/-6

Reuse shared pad-content response fixtures

• Replaces the local JSON response builder with the common test utility.

tests/js/lib/pad-content.test.js

responses.jsAdd reusable fetch-response test fixtures +30/-0

Add reusable fetch-response test fixtures

• Introduces shared builders for JSON responses, error responses, non-JSON pages, and bodies interrupted after their status line.

tests/js/responses.js

viewer-main.test.jsReuse shared viewer response fixtures +1/-6

Reuse shared viewer response fixtures

• Replaces the viewer test's local JSON response builder with the common fixture module.

tests/js/viewer-main.test.js

EmbedControllerTest.phpVerify localized embed failure template data +5/-0

Verify localized embed failure template data

• Asserts that embed and embed-create controller responses contain their appropriate translated unanswered and fallback messages.

tests/phpunit/unit/EmbedControllerTest.php

Documentation (2) +12 / -4
api-reference.mdDocument create timing and application error guarantees +3/-2

Document create timing and application error guarantees

• Documents that application errors are JSON, clarifies gateway-response interpretation, and records that embedded creation has no client timeout. It also links unknown create outcomes to the host contract.

docs/api-reference.md

architecture.mdDefine the expanded embed-create host contract +9/-2

Define the expanded embed-create host contract

• Specifies the 'code' and 'retryable' fields, all four failure reasons, and when creation outcomes remain unknown. It also centralizes the rules for identifying responses not produced by the app.

docs/architecture.md

Other (10) +16 / -0
fetch-helpers-BUxbvlK6.chunk.mjs.licenseAdd fetch-helper license sidecar +0/-0

Add fetch-helper license sidecar

• Adds the generated empty license sidecar associated with the rebuilt fetch-helper chunk.

js/fetch-helpers-BUxbvlK6.chunk.mjs.license

pad-open-flow-By_FzSKk.chunk.mjs.licenseAdd pad-open-flow license sidecar +0/-0

Add pad-open-flow license sidecar

• Adds the generated empty license sidecar associated with the rebuilt pad-open-flow chunk.

js/pad-open-flow-By_FzSKk.chunk.mjs.license

de.jsAdd German create-failure translations +2/-0

Add German create-failure translations

• Adds German translations for an unknown create outcome and the generic creation failure fallback.

l10n/de.js

de.jsonAdd German create-failure catalog entries +2/-0

Add German create-failure catalog entries

• Adds JSON catalog entries for the new German embed-create failure messages.

l10n/de.json

es.jsAdd Spanish create-failure translations +2/-0

Add Spanish create-failure translations

• Adds Spanish translations for an unknown create outcome and the generic creation failure fallback.

l10n/es.js

es.jsonAdd Spanish create-failure catalog entries +2/-0

Add Spanish create-failure catalog entries

• Adds JSON catalog entries for the new Spanish embed-create failure messages.

l10n/es.json

fr.jsAdd French create-failure translations +2/-0

Add French create-failure translations

• Adds French translations for an unknown create outcome and the generic creation failure fallback.

l10n/fr.js

fr.jsonAdd French create-failure catalog entries +2/-0

Add French create-failure catalog entries

• Adds JSON catalog entries for the new French embed-create failure messages.

l10n/fr.json

it.jsAdd Italian create-failure translations +2/-0

Add Italian create-failure translations

• Adds Italian translations for an unknown create outcome and the generic creation failure fallback.

l10n/it.js

it.jsonAdd Italian create-failure catalog entries +2/-0

Add Italian create-failure catalog entries

• Adds JSON catalog entries for the new Italian embed-create failure messages.

l10n/it.json

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can type 'qodo, fix this' on a finding and the fix lands right on your PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@Jaggob
Jaggob merged commit d32ec14 into main Sep 26, 2026
25 checks passed
@Jaggob
Jaggob deleted the fix/embed-create-timeout branch September 26, 2026 10:43
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