From 9144ea04ec432e36b30422873b878dbb6ac6e0f6 Mon Sep 17 00:00:00 2001 From: Artem Kosolap Date: Wed, 28 Oct 2026 19:19:39 -0700 Subject: [PATCH] fix: a 400 directLinkedIn.unknown to a LinkedIn send is outcome.unknown Reply answers an exception escaping a LinkedIn send, and a provider answer it cannot map, as a 400 with the code directLinkedIn.unknown and a generic detail. The send may already be made, so mapping it to reply.refused told the caller nothing was sent when that is not known. On the send call of the four LinkedIn sends it is now outcome.unknown (exit 3), with status 400, Reply's code kept in reply_code, and the hint that a repeat may send it twice; a send Reply names in the answer stays under details. Under any other status, on send-email, and on the import of a send given a person's fields, the code maps as before. An email's exception is a 500, already outcome.unknown, and a 400 contact.emailSendingFailed stays a refusal. The help and the README say so. --- README.md | 4 +- src/__tests__/operations/commands.test.ts | 2 + src/__tests__/operations/direct-send.test.ts | 57 ++++++++++++++++++++ src/operations/commands.ts | 4 +- src/operations/direct-send.ts | 11 ++-- 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ab3cee6..edc54b0 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,9 @@ reply schedule list --json re-run. On exit 3 (outcome unknown) a reply or a send may already have gone, and running it again may send it twice, unless the hint says nothing was sent. A direct send's exit 3 comes from a lost answer, a 5xx, or a 400 to the send whose JSON carries neither a `code` nor `errors[]`, which is -Reply failing while it made the send; a 400 with `errors[]` is `reply.invalid_request` and sent +Reply failing while it made the send. On a LinkedIn send, a 400 `directLinkedIn.unknown` is the +same: Reply failed while making the send or could not map the provider's answer, and its code is +kept in `reply_code`. A 400 with `errors[]` is `reply.invalid_request` and sent nothing. A send whose contact's import lost its answer was never made, and its hint says so; run again, it answers `contact.already_held` if that import did create the contact. An `--idempotency-key` does not change this: a Reply that does not know the header yet would make the send again. diff --git a/src/__tests__/operations/commands.test.ts b/src/__tests__/operations/commands.test.ts index 5da9511..c6e3845 100644 --- a/src/__tests__/operations/commands.test.ts +++ b/src/__tests__/operations/commands.test.ts @@ -307,6 +307,8 @@ describe('operations/commands', ()=>{ + 'refusal\'s own details; its code is the refusal\'s own'); expect(flat, text).toContain('A 400 to the send with neither a code nor errors[] is Reply failing while it made the send: ' + 'like a 5xx or a lost answer, it is outcome.unknown (exit 3)'); + expect(flat, text).toContain('So is a 400 directLinkedIn.unknown to a LinkedIn send, Reply\'s answer when it failed while ' + + 'making the send or could not map the provider\'s answer; Reply\'s code is kept in reply_code'); // Exactly the codes its endpoint publishes, then the shared ones, as the help lists them. const listed = text.split('\nErrors')[1].split('\nExit codes')[0].split('\n').slice(1).map(l=>l.trim()); expect(listed, name).toEqual([...codes, 'idempotency_key.in_use', 'idempotency_key.reused', 'access.denied', 'access.feature_unavailable', 'rate_limited', 'reply.unavailable', diff --git a/src/__tests__/operations/direct-send.test.ts b/src/__tests__/operations/direct-send.test.ts index 5f7bf35..08b62c1 100644 --- a/src/__tests__/operations/direct-send.test.ts +++ b/src/__tests__/operations/direct-send.test.ts @@ -530,6 +530,8 @@ describe('a direct send to a person given by their fields', ()=>{ [[nobody(), nobody(), json({items: [{id: null, status: 'failed', error: 'Invalid data'}]})], 'reply.refused', 1], // A 400 with neither a code nor errors[] is read as Reply's exception only on the send call, never on the import. [[nobody(), nobody(), json({title: 'Bad Request', status: 400}, 400, {'Content-Type': 'application/problem+json'})], 'reply.refused', 1], + // So is the LinkedIn sends' exception code. + [[nobody(), nobody(), json({code: 'directLinkedIn.unknown', detail: 'Unexpected error.'}, 400)], 'reply.refused', 1], ]; for (const [answers, code, exit_code] of cases) { @@ -1016,3 +1018,58 @@ describe('a 400 on the send call with neither a code nor errors[]', ()=>{ } }); }); + +describe('a 400 directLinkedIn.unknown on the send call', ()=>{ + const LINKEDIN: [Direct_send_name, unknown][] = [ + ['linkedin-connect', {note: NOTE}], + ['linkedin-message', {body: TEXT}], + ['linkedin-inmail', {subject: SUBJECT, body: TEXT}], + ['linkedin-voice', {script: SCRIPT}], + ]; + const unknown = (status: number, extra: Record = {})=> + json({code: 'directLinkedIn.unknown', detail: 'An unexpected error occurred.', ...extra}, status, {'Content-Type': 'application/problem+json'}); + + it('is Reply\'s exception path on a LinkedIn send: outcome.unknown (exit 3), status 400 and Reply\'s code kept, made once, ' + + 'and a repeat may send it twice', async()=>{ + for (const [name, message] of LINKEDIN) + { + mock_fetch.mockReset(); + answer(unknown(400)); + const e = await failure(name, message, LINKEDIN_OPTS); + expect(e, name).toMatchObject({code: 'outcome.unknown', exit_code: 3, status: 400, reply_code: 'directLinkedIn.unknown', + ids: {contact_id: 456}}); + expect((e as {hint?: string}).hint ?? '', name).toContain('may send it twice'); + expect(mock_fetch, name).toHaveBeenCalledTimes(1); + } + }); + + it('keeps the send it names under details, should Reply give one', async()=>{ + answer(unknown(400, {reference: 'linkedin-1042', state: 'failed'})); + const e = await failure('linkedin-message', {body: TEXT}, LINKEDIN_OPTS); + expect(e).toMatchObject({code: 'outcome.unknown', exit_code: 3, details: {reference: 'linkedin-1042', state: 'failed'}}); + }); + + it('under any other status it maps as before', async()=>{ + for (const [name, message] of LINKEDIN) + { + for (const [status, code] of [[409, 'reply.refused'], [403, 'access.denied'], [404, 'reply.refused']] as const) + { + mock_fetch.mockReset(); + answer(unknown(status)); + const e = await failure(name, message, LINKEDIN_OPTS); + expect(e, `${name} ${status}`).toMatchObject({code, exit_code: 1, status, reply_code: 'directLinkedIn.unknown'}); + } + } + }); + + it('on send-email it maps as before, and Reply\'s contact.emailSendingFailed stays a refusal: nothing was sent', async()=>{ + for (const reply_code of ['directLinkedIn.unknown', 'contact.emailSendingFailed']) + { + mock_fetch.mockReset(); + answer(json({code: reply_code, detail: 'The email could not be sent.'}, 400, {'Content-Type': 'application/problem+json'})); + const e = await failure('send-email', {subject: SUBJECT, body: BODY}, EMAIL_OPTS); + expect(e, reply_code).toMatchObject({code: 'reply.refused', exit_code: 1, status: 400, reply_code, ids: {contact_id: 456}}); + expect(mock_fetch, reply_code).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/operations/commands.ts b/src/operations/commands.ts index c96e5e7..d67ed67 100644 --- a/src/operations/commands.ts +++ b/src/operations/commands.ts @@ -311,7 +311,9 @@ const LATER = 'With --send-after Reply holds the send until that moment, given w + 'for in details: reference, state and send_after, each only when Reply gives it. Any other refusal of a send Reply\n' + 'already holds names that send the same way, beside the refusal\'s own details; its code is the refusal\'s own.\n' + 'A 400 to the send with neither a code nor errors[] is Reply failing while it made the send: like a 5xx or a lost\n' - + 'answer, it is outcome.unknown (exit 3). A 400 with errors[] is reply.invalid_request, and nothing was sent.\n'; + + 'answer, it is outcome.unknown (exit 3). So is a 400 directLinkedIn.unknown to a LinkedIn send, Reply\'s answer when\n' + + 'it failed while making the send or could not map the provider\'s answer; Reply\'s code is kept in reply_code. A 400\n' + + 'with errors[] is reply.invalid_request, and nothing was sent.\n'; // The refusals of a send's key, the same on every direct send, after each send's own. const KEY_CODES = ['idempotency_key.in_use', 'idempotency_key.reused']; diff --git a/src/operations/direct-send.ts b/src/operations/direct-send.ts index f88a7bb..7d2b846 100644 --- a/src/operations/direct-send.ts +++ b/src/operations/direct-send.ts @@ -229,14 +229,19 @@ const known_refusal = (a: Answer, table: Record): Operation_err const exception_of_send = (a: Answer): boolean=> a.status === 400 && is_object(a.data) && a.data.code === undefined && a.data.errors === undefined; +// The LinkedIn sends answer their own exception path, and a provider answer Reply cannot map, as a +// 400 directLinkedIn.unknown: the send may already be made. An email's exception is a 500. +const linkedin_exception = (a: Answer, by: Channel): boolean=> + by === 'linkedin' && a.status === 400 && reply_code_of(a) === 'directLinkedIn.unknown'; + const refusal_of_send = (a: Answer, contact_id: number, by: Channel): Operation_error=>{ - if (exception_of_send(a)) + const reply_code = reply_code_of(a); + if (exception_of_send(a) || linkedin_exception(a, by)) { return new Unknown_outcome_error('Reply failed while handling the send; it may or may not have been made.', { - status: a.status, detail: detail_of(a), + status: a.status, reply_code, detail: detail_of(a), }); } - const reply_code = reply_code_of(a); if (a.status === 404 && (reply_code === 'contact.notFound' || reply_code === 'directLinkedIn.contactNotFound')) { return contact_not_found(contact_id, a);