Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/operations/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
57 changes: 57 additions & 0 deletions src/__tests__/operations/direct-send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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<string, unknown> = {})=>
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);
}
});
});
4 changes: 3 additions & 1 deletion src/operations/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
11 changes: 8 additions & 3 deletions src/operations/direct-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,19 @@ const known_refusal = (a: Answer, table: Record<string, Refusal>): 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);
Expand Down
Loading