mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 11:59:54 -06:00
fix: improve survey response queue robustness to prevent data loss (#6959)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "عناصر الترتيب",
|
||||
"respondents_will_not_see_this_card": "لن يرى المستجيبون هذه البطاقة",
|
||||
"retry": "إعادة المحاولة",
|
||||
"retrying": "إعادة المحاولة...",
|
||||
"select_a_date": "اختر تاريخًا",
|
||||
"select_for_ranking": "اختر {item} للترتيب",
|
||||
"sending_responses": "جارٍ إرسال الردود...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Ranking-Elemente",
|
||||
"respondents_will_not_see_this_card": "Befragte werden diese Karte nicht sehen",
|
||||
"retry": "Wiederholen",
|
||||
"retrying": "Wird wiederholt...",
|
||||
"select_a_date": "Datum auswählen",
|
||||
"select_for_ranking": "{item} für Ranking auswählen",
|
||||
"sending_responses": "Antworten werden gesendet...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Ranking Items",
|
||||
"respondents_will_not_see_this_card": "Respondents will not see this card",
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying...",
|
||||
"select_a_date": "Select a date",
|
||||
"select_for_ranking": "Select {item} for ranking",
|
||||
"sending_responses": "Sending responses...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Elementos de clasificación",
|
||||
"respondents_will_not_see_this_card": "Los encuestados no verán esta tarjeta",
|
||||
"retry": "Reintentar",
|
||||
"retrying": "Reintentando...",
|
||||
"select_a_date": "Seleccionar una fecha",
|
||||
"select_for_ranking": "Seleccionar {item} para clasificación",
|
||||
"sending_responses": "Enviando respuestas...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Éléments de classement",
|
||||
"respondents_will_not_see_this_card": "Les répondants ne verront pas cette carte",
|
||||
"retry": "Réessayer",
|
||||
"retrying": "Nouvelle tentative...",
|
||||
"select_a_date": "Sélectionner une date",
|
||||
"select_for_ranking": "Sélectionner {item} pour le classement",
|
||||
"sending_responses": "Envoi des réponses...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "रैंकिंग आइटम",
|
||||
"respondents_will_not_see_this_card": "उत्तरदाता इस कार्ड को नहीं देखेंगे",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"retrying": "पुनः प्रयास कर रहे हैं...",
|
||||
"select_a_date": "एक तिथि चुनें",
|
||||
"select_for_ranking": "रैंकिंग के लिए {item} चुनें",
|
||||
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Elementi di classifica",
|
||||
"respondents_will_not_see_this_card": "I rispondenti non vedranno questa scheda",
|
||||
"retry": "Riprova",
|
||||
"retrying": "Riprovando...",
|
||||
"select_a_date": "Seleziona una data",
|
||||
"select_for_ranking": "Seleziona {item} per la classifica",
|
||||
"sending_responses": "Invio risposte in corso...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "ランキング項目",
|
||||
"respondents_will_not_see_this_card": "回答者はこのカードを見ることができません",
|
||||
"retry": "再試行",
|
||||
"retrying": "再試行中...",
|
||||
"select_a_date": "日付を選択",
|
||||
"select_for_ranking": "ランキング用に{item}を選択",
|
||||
"sending_responses": "回答を送信中...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Items rangschikken",
|
||||
"respondents_will_not_see_this_card": "Respondenten zien deze kaart niet",
|
||||
"retry": "Opnieuw proberen",
|
||||
"retrying": "Opnieuw proberen...",
|
||||
"select_a_date": "Selecteer een datum",
|
||||
"select_for_ranking": "Selecteer {item} voor rangschikking",
|
||||
"sending_responses": "Reacties verzenden...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Itens de classificação",
|
||||
"respondents_will_not_see_this_card": "Os respondentes não verão este cartão",
|
||||
"retry": "Tentar novamente",
|
||||
"retrying": "Tentando novamente...",
|
||||
"select_a_date": "Selecione uma data",
|
||||
"select_for_ranking": "Selecione {item} para classificação",
|
||||
"sending_responses": "Enviando respostas...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Clasificare articole",
|
||||
"respondents_will_not_see_this_card": "Respondenții nu vor vedea acest card",
|
||||
"retry": "Reîncearcă",
|
||||
"retrying": "Se reîncearcă...",
|
||||
"select_a_date": "Selectează o dată",
|
||||
"select_for_ranking": "Selectează {item} pentru clasificare",
|
||||
"sending_responses": "Trimiterea răspunsurilor...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Ранжирование элементов",
|
||||
"respondents_will_not_see_this_card": "Респонденты не увидят эту карточку",
|
||||
"retry": "Повторить",
|
||||
"retrying": "Повторная попытка...",
|
||||
"select_a_date": "Выберите дату",
|
||||
"select_for_ranking": "Выберите {item} для ранжирования",
|
||||
"sending_responses": "Отправка ответов...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Rangordna objekt",
|
||||
"respondents_will_not_see_this_card": "Respondenter kommer inte att se detta kort",
|
||||
"retry": "Försök igen",
|
||||
"retrying": "Försöker igen...",
|
||||
"select_a_date": "Välj ett datum",
|
||||
"select_for_ranking": "Välj {item} för rangordning",
|
||||
"sending_responses": "Skickar svar...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "Reyting elementlari",
|
||||
"respondents_will_not_see_this_card": "Javob beruvchilar ushbu kartani ko'rmaydi",
|
||||
"retry": "Qayta urinib ko'ring",
|
||||
"retrying": "Qayta urinilmoqda...",
|
||||
"select_a_date": "Sanani tanlang",
|
||||
"select_for_ranking": "Reyting uchun {item} ni tanlang",
|
||||
"sending_responses": "Javoblar yuborilmoqda...",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ranking_items": "排名项目",
|
||||
"respondents_will_not_see_this_card": "受访者将不会看到此卡片",
|
||||
"retry": "重试",
|
||||
"retrying": "重试中...",
|
||||
"select_a_date": "选择日期",
|
||||
"select_for_ranking": "选择{item}进行排名",
|
||||
"sending_responses": "正在发送响应...",
|
||||
|
||||
@@ -5,12 +5,18 @@ import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { processResponseData } from "@/lib/response";
|
||||
|
||||
interface ResponseErrorComponentProps {
|
||||
questions: TSurveyElement[];
|
||||
responseData: TResponseData;
|
||||
onRetry?: () => void;
|
||||
readonly questions: TSurveyElement[];
|
||||
readonly responseData: TResponseData;
|
||||
readonly onRetry?: () => void;
|
||||
readonly isRetrying?: boolean;
|
||||
}
|
||||
|
||||
export function ResponseErrorComponent({ questions, responseData, onRetry }: ResponseErrorComponentProps) {
|
||||
export function ResponseErrorComponent({
|
||||
questions,
|
||||
responseData,
|
||||
onRetry,
|
||||
isRetrying = false,
|
||||
}: ResponseErrorComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fb-flex fb-flex-col fb-bg-white fb-p-4">
|
||||
@@ -23,14 +29,14 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
|
||||
{t("common.please_retry_now_or_try_again_later")}
|
||||
</p>
|
||||
<div className="fb-mt-4 fb-rounded-lg fb-border fb-border-slate-200 fb-bg-slate-100 fb-px-4 fb-py-5">
|
||||
<div className="fb-flex fb-max-h-36 fb-flex-1 fb-flex-col fb-space-y-3 fb-overflow-y-scroll">
|
||||
<div className="fb-flex fb-max-h-48 fb-flex-1 fb-flex-col fb-space-y-2 fb-overflow-y-scroll">
|
||||
{questions.map((question, index) => {
|
||||
const response = responseData[question.id];
|
||||
if (!response) return;
|
||||
return (
|
||||
<div className="fb-flex fb-flex-col" key={`response-${index.toString()}`}>
|
||||
<span className="fb-text-sm fb-leading-6 fb-text-slate-900">{`${t("common.question")} ${(index + 1).toString()}`}</span>
|
||||
<span className="fb-mt-1 fb-text-sm fb-font-semibold fb-leading-6 fb-text-slate-900">
|
||||
<span className="fb-text-sm fb-leading-5 fb-text-slate-900">{`${t("common.question")} ${(index + 1).toString()}`}</span>
|
||||
<span className="fb-text-sm fb-font-semibold fb-leading-5 fb-text-slate-900">
|
||||
{processResponseData(response)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -40,11 +46,14 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
|
||||
</div>
|
||||
<div className="fb-mt-4 fb-flex fb-flex-1 fb-flex-row fb-items-center fb-justify-end fb-space-x-2">
|
||||
<SubmitButton
|
||||
buttonLabel={t("common.retry")}
|
||||
buttonLabel={isRetrying ? t("common.retrying") : t("common.retry")}
|
||||
isLastQuestion={false}
|
||||
onClick={() => {
|
||||
onRetry?.();
|
||||
if (!isRetrying) {
|
||||
onRetry?.();
|
||||
}
|
||||
}}
|
||||
disabled={isRetrying}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function Survey({
|
||||
{
|
||||
appUrl,
|
||||
environmentId,
|
||||
retryAttempts: 2,
|
||||
retryAttempts: 4,
|
||||
onResponseSendingFailed: (_, errorCode?: TResponseErrorCodesEnum) => {
|
||||
setShowError(true);
|
||||
setErrorType(errorCode);
|
||||
@@ -185,6 +185,7 @@ export function Survey({
|
||||
|
||||
const [errorType, setErrorType] = useState<TResponseErrorCodesEnum | undefined>(undefined);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [isResponseSendingFinished, setIsResponseSendingFinished] = useState(
|
||||
!getSetIsResponseSendingFinished
|
||||
);
|
||||
@@ -710,11 +711,16 @@ export function Survey({
|
||||
setBlockId(prevBlockId);
|
||||
};
|
||||
|
||||
const retryResponse = () => {
|
||||
const retryResponse = async () => {
|
||||
if (responseQueue) {
|
||||
setShowError(false);
|
||||
setErrorType(undefined);
|
||||
void responseQueue.processQueue();
|
||||
setIsRetrying(true);
|
||||
const result = await responseQueue.processQueue();
|
||||
setIsRetrying(false);
|
||||
|
||||
if (result.success) {
|
||||
setShowError(false);
|
||||
setErrorType(undefined);
|
||||
}
|
||||
} else {
|
||||
onRetry?.();
|
||||
}
|
||||
@@ -726,9 +732,10 @@ export function Survey({
|
||||
case TResponseErrorCodesEnum.ResponseSendingError:
|
||||
return (
|
||||
<ResponseErrorComponent
|
||||
responseData={responseData}
|
||||
responseData={responseQueue?.getUnsentData() ?? responseData}
|
||||
questions={questions}
|
||||
onRetry={retryResponse}
|
||||
isRetrying={isRetrying}
|
||||
/>
|
||||
);
|
||||
case TResponseErrorCodesEnum.RecaptchaError:
|
||||
|
||||
@@ -55,8 +55,10 @@ export class ResponseQueue {
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
async processQueue() {
|
||||
if (this.isRequestInProgress || this.queue.length === 0) return;
|
||||
async processQueue(): Promise<{ success: boolean }> {
|
||||
if (this.isRequestInProgress || this.queue.length === 0) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
this.isRequestInProgress = true;
|
||||
const responseUpdate = this.queue[0];
|
||||
@@ -65,8 +67,10 @@ export class ResponseQueue {
|
||||
|
||||
if (result.success) {
|
||||
this.handleSuccessfulResponse(responseUpdate, result.quotaFullResponse);
|
||||
return { success: true };
|
||||
} else {
|
||||
this.handleFailedResponse(responseUpdate, result.isRecaptchaError);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,18 +92,41 @@ export class ResponseQueue {
|
||||
quotaFullResponse = res.data;
|
||||
}
|
||||
|
||||
if (attempts > 0) {
|
||||
console.log(`Formbricks: Response sent successfully after ${attempts + 1} attempts`);
|
||||
}
|
||||
|
||||
return { success: true, quotaFullResponse: quotaFullResponse ?? undefined };
|
||||
}
|
||||
|
||||
if (this.isRecaptchaError(res.error)) {
|
||||
console.error("Formbricks: Recaptcha verification failed", {
|
||||
error: res.error,
|
||||
responseId: this.surveyState.responseId,
|
||||
});
|
||||
return { success: false, isRecaptchaError: true };
|
||||
}
|
||||
|
||||
console.error(`Formbricks: Failed to send response. Retrying... ${attempts}`);
|
||||
await delay(1000);
|
||||
console.error(`Formbricks: Response send failed`, {
|
||||
attempt: attempts + 1,
|
||||
maxAttempts: this.config.retryAttempts,
|
||||
error: res.error,
|
||||
responseId: this.surveyState.responseId,
|
||||
queueLength: this.queue.length,
|
||||
});
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s
|
||||
const backoffMs = 1000 * Math.pow(2, attempts);
|
||||
await delay(backoffMs);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
console.error(`Formbricks: Failed to send response after ${this.config.retryAttempts} attempts`, {
|
||||
queueLength: this.queue.length,
|
||||
responseId: this.surveyState.responseId,
|
||||
surveyId: this.surveyState.surveyId,
|
||||
});
|
||||
|
||||
return { success: false, isRecaptchaError: false };
|
||||
}
|
||||
|
||||
@@ -133,7 +160,6 @@ export class ResponseQueue {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Failed to send response after ${this.config.retryAttempts} attempts.`);
|
||||
this.config.onResponseSendingFailed?.(responseUpdate, TResponseErrorCodesEnum.ResponseSendingError);
|
||||
}
|
||||
|
||||
@@ -198,4 +224,9 @@ export class ResponseQueue {
|
||||
updateSurveyState(surveyState: SurveyState) {
|
||||
this.surveyState = surveyState;
|
||||
}
|
||||
|
||||
// get unsent response data from queue
|
||||
getUnsentData(): TResponseUpdate["data"] {
|
||||
return this.queue.reduce((acc, item) => ({ ...acc, ...item.data }), {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("ResponseQueue", () => {
|
||||
});
|
||||
|
||||
test("add accumulates response, sets survey state, and processes queue", async () => {
|
||||
vi.spyOn(queue, "processQueue").mockImplementation(() => Promise.resolve());
|
||||
vi.spyOn(queue, "processQueue").mockImplementation(() => Promise.resolve({ success: true }));
|
||||
queue.add(responseUpdate);
|
||||
expect(surveyState.accumulateResponse).toHaveBeenCalledWith(responseUpdate);
|
||||
expect(config.setSurveyState).toHaveBeenCalledWith(surveyState);
|
||||
@@ -192,4 +192,86 @@ describe("ResponseQueue", () => {
|
||||
queue.updateSurveyState(newState);
|
||||
expect(queue["surveyState"]).toBe(newState);
|
||||
});
|
||||
|
||||
test("processQueueAsync returns success false if queue empty", async () => {
|
||||
const result = await queue.processQueue();
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("processQueueAsync returns success false if request in progress", async () => {
|
||||
queue["isRequestInProgress"] = true;
|
||||
const result = await queue.processQueue();
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("processQueueAsync returns success true on successful send", async () => {
|
||||
queue.queue.push(responseUpdate);
|
||||
vi.spyOn(queue, "sendResponse").mockResolvedValue(ok(true));
|
||||
const result = await queue.processQueue();
|
||||
expect(result.success).toBe(true);
|
||||
expect(queue.queue.length).toBe(0);
|
||||
});
|
||||
|
||||
test("processQueueAsync returns success false after max attempts", async () => {
|
||||
queue.queue.push(responseUpdate);
|
||||
vi.spyOn(queue, "sendResponse").mockResolvedValue(
|
||||
err({
|
||||
code: "internal_server_error",
|
||||
message: "An error occurred while sending the response.",
|
||||
status: 500,
|
||||
})
|
||||
);
|
||||
const result = await queue.processQueue();
|
||||
expect(result.success).toBe(false);
|
||||
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
|
||||
responseUpdate,
|
||||
TResponseErrorCodesEnum.ResponseSendingError
|
||||
);
|
||||
});
|
||||
|
||||
test("processQueueAsync returns success false on recaptcha error", async () => {
|
||||
queue.queue.push(responseUpdate);
|
||||
vi.spyOn(queue, "sendResponse").mockResolvedValue(
|
||||
err({
|
||||
code: "internal_server_error",
|
||||
message: "An error occurred while sending the response.",
|
||||
status: 500,
|
||||
details: {
|
||||
code: "recaptcha_verification_failed",
|
||||
},
|
||||
})
|
||||
);
|
||||
const result = await queue.processQueue();
|
||||
expect(result.success).toBe(false);
|
||||
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
|
||||
responseUpdate,
|
||||
TResponseErrorCodesEnum.RecaptchaError
|
||||
);
|
||||
});
|
||||
|
||||
test("getUnsentData returns empty object when queue is empty", () => {
|
||||
const unsentData = queue.getUnsentData();
|
||||
expect(unsentData).toEqual({});
|
||||
});
|
||||
|
||||
test("getUnsentData returns data from single item in queue", () => {
|
||||
queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
|
||||
const unsentData = queue.getUnsentData();
|
||||
expect(unsentData).toEqual({ q1: "answer1" });
|
||||
});
|
||||
|
||||
test("getUnsentData aggregates data from multiple items in queue", () => {
|
||||
queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
|
||||
queue.queue.push({ data: { q2: "answer2" }, hiddenFields: {}, finished: false });
|
||||
queue.queue.push({ data: { q3: "answer3" }, hiddenFields: {}, finished: true });
|
||||
const unsentData = queue.getUnsentData();
|
||||
expect(unsentData).toEqual({ q1: "answer1", q2: "answer2", q3: "answer3" });
|
||||
});
|
||||
|
||||
test("getUnsentData overwrites duplicate keys with latest value", () => {
|
||||
queue.queue.push({ data: { q1: "answer1" }, hiddenFields: {}, finished: false });
|
||||
queue.queue.push({ data: { q1: "updated_answer1", q2: "answer2" }, hiddenFields: {}, finished: false });
|
||||
const unsentData = queue.getUnsentData();
|
||||
expect(unsentData).toEqual({ q1: "updated_answer1", q2: "answer2" });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user