Skip to content

Commit 06b8d1c

Browse files
committed
Add a ResponseError for registry API interaction.
The intent here is to make it easier to work with API errors. This also includes some new tests for checking network errors.
1 parent 340656e commit 06b8d1c

File tree

2 files changed

+274
-33
lines changed

2 files changed

+274
-33
lines changed

crates/crates-io/lib.rs

Lines changed: 105 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
#![allow(clippy::identity_op)] // used for vertical alignment
33

44
use std::collections::BTreeMap;
5+
use std::fmt;
56
use std::fs::File;
67
use std::io::prelude::*;
78
use std::io::{Cursor, SeekFrom};
89
use std::time::Instant;
910

10-
use anyhow::{bail, Context, Result};
11+
use anyhow::{bail, format_err, Context, Result};
1112
use curl::easy::{Easy, List};
1213
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
1314
use serde::{Deserialize, Serialize};
@@ -121,6 +122,70 @@ struct Crates {
121122
crates: Vec<Crate>,
122123
meta: TotalCrates,
123124
}
125+
126+
#[derive(Debug)]
127+
pub enum ResponseError {
128+
Curl(curl::Error),
129+
Api {
130+
code: u32,
131+
errors: Vec<String>,
132+
},
133+
Code {
134+
code: u32,
135+
headers: Vec<String>,
136+
body: String,
137+
},
138+
Other(anyhow::Error),
139+
}
140+
141+
impl std::error::Error for ResponseError {
142+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
143+
match self {
144+
ResponseError::Curl(..) => None,
145+
ResponseError::Api { .. } => None,
146+
ResponseError::Code { .. } => None,
147+
ResponseError::Other(e) => Some(e.as_ref()),
148+
}
149+
}
150+
}
151+
152+
impl fmt::Display for ResponseError {
153+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
154+
match self {
155+
ResponseError::Curl(e) => write!(f, "{}", e),
156+
ResponseError::Api { code, errors } => write!(
157+
f,
158+
"api errors (status {} {}): {}",
159+
code,
160+
reason(*code),
161+
errors.join(", ")
162+
),
163+
ResponseError::Code {
164+
code,
165+
headers,
166+
body,
167+
} => write!(
168+
f,
169+
"failed to get a 200 OK response, got {}\n\
170+
headers:\n\
171+
\t{}\n\
172+
body:\n\
173+
{}",
174+
code,
175+
headers.join("\n\t"),
176+
body
177+
),
178+
ResponseError::Other(..) => write!(f, "invalid response from server"),
179+
}
180+
}
181+
}
182+
183+
impl From<curl::Error> for ResponseError {
184+
fn from(error: curl::Error) -> Self {
185+
ResponseError::Curl(error)
186+
}
187+
}
188+
124189
impl Registry {
125190
/// Creates a new `Registry`.
126191
///
@@ -214,7 +279,25 @@ impl Registry {
214279
headers.append(&format!("Authorization: {}", token))?;
215280
self.handle.http_headers(headers)?;
216281

217-
let body = self.handle(&mut |buf| body.read(buf).unwrap_or(0))?;
282+
let started = Instant::now();
283+
let body = self
284+
.handle(&mut |buf| body.read(buf).unwrap_or(0))
285+
.map_err(|e| match e {
286+
ResponseError::Code { code, .. }
287+
if code == 503
288+
&& started.elapsed().as_secs() >= 29
289+
&& self.host_is_crates_io() =>
290+
{
291+
format_err!(
292+
"Request timed out after 30 seconds. If you're trying to \
293+
upload a crate it may be too large. If the crate is under \
294+
10MB in size, you can email [email protected] for assistance.\n\
295+
Total size was {}.",
296+
tarball_len
297+
)
298+
}
299+
_ => e.into(),
300+
})?;
218301

219302
let response = if body.is_empty() {
220303
"{}".parse()?
@@ -308,15 +391,18 @@ impl Registry {
308391
self.handle.upload(true)?;
309392
self.handle.in_filesize(body.len() as u64)?;
310393
self.handle(&mut |buf| body.read(buf).unwrap_or(0))
394+
.map_err(|e| e.into())
311395
}
312-
None => self.handle(&mut |_| 0),
396+
None => self.handle(&mut |_| 0).map_err(|e| e.into()),
313397
}
314398
}
315399

316-
fn handle(&mut self, read: &mut dyn FnMut(&mut [u8]) -> usize) -> Result<String> {
400+
fn handle(
401+
&mut self,
402+
read: &mut dyn FnMut(&mut [u8]) -> usize,
403+
) -> std::result::Result<String, ResponseError> {
317404
let mut headers = Vec::new();
318405
let mut body = Vec::new();
319-
let started;
320406
{
321407
let mut handle = self.handle.transfer();
322408
handle.read_function(|buf| Ok(read(buf)))?;
@@ -325,50 +411,36 @@ impl Registry {
325411
Ok(data.len())
326412
})?;
327413
handle.header_function(|data| {
328-
headers.push(String::from_utf8_lossy(data).into_owned());
414+
// Headers contain trailing \r\n, trim them to make it easier
415+
// to work with.
416+
let s = String::from_utf8_lossy(data).trim().to_string();
417+
headers.push(s);
329418
true
330419
})?;
331-
started = Instant::now();
332420
handle.perform()?;
333421
}
334422

335423
let body = match String::from_utf8(body) {
336424
Ok(body) => body,
337-
Err(..) => bail!("response body was not valid utf-8"),
425+
Err(..) => {
426+
return Err(ResponseError::Other(format_err!(
427+
"response body was not valid utf-8"
428+
)))
429+
}
338430
};
339431
let errors = serde_json::from_str::<ApiErrorList>(&body)
340432
.ok()
341433
.map(|s| s.errors.into_iter().map(|s| s.detail).collect::<Vec<_>>());
342434

343435
match (self.handle.response_code()?, errors) {
344-
(0, None) | (200, None) => {}
345-
(503, None) if started.elapsed().as_secs() >= 29 && self.host_is_crates_io() => bail!(
346-
"Request timed out after 30 seconds. If you're trying to \
347-
upload a crate it may be too large. If the crate is under \
348-
10MB in size, you can email [email protected] for assistance."
349-
),
350-
(code, Some(errors)) => {
351-
let reason = reason(code);
352-
bail!(
353-
"api errors (status {} {}): {}",
354-
code,
355-
reason,
356-
errors.join(", ")
357-
)
358-
}
359-
(code, None) => bail!(
360-
"failed to get a 200 OK response, got {}\n\
361-
headers:\n\
362-
\t{}\n\
363-
body:\n\
364-
{}",
436+
(0, None) | (200, None) => Ok(body),
437+
(code, Some(errors)) => Err(ResponseError::Api { code, errors }),
438+
(code, None) => Err(ResponseError::Code {
365439
code,
366-
headers.join("\n\t"),
440+
headers,
367441
body,
368-
),
442+
}),
369443
}
370-
371-
Ok(body)
372444
}
373445
}
374446

tests/testsuite/publish.rs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,3 +1457,172 @@ Caused by:
14571457
))
14581458
.run();
14591459
}
1460+
1461+
#[cargo_test]
1462+
fn api_error_json() {
1463+
// Registry returns an API error.
1464+
let t = registry::RegistryBuilder::new().build_api_server(&|_headers| {
1465+
(403, &r#"{"errors": [{"detail": "you must be logged in"}]}"#)
1466+
});
1467+
1468+
let p = project()
1469+
.file(
1470+
"Cargo.toml",
1471+
r#"
1472+
[project]
1473+
name = "foo"
1474+
version = "0.0.1"
1475+
authors = []
1476+
license = "MIT"
1477+
description = "foo"
1478+
documentation = "foo"
1479+
homepage = "foo"
1480+
repository = "foo"
1481+
"#,
1482+
)
1483+
.file("src/lib.rs", "")
1484+
.build();
1485+
1486+
p.cargo("publish --no-verify --registry alternative")
1487+
.with_status(101)
1488+
.with_stderr(
1489+
"\
1490+
[UPDATING] [..]
1491+
[PACKAGING] foo v0.0.1 [..]
1492+
[UPLOADING] foo v0.0.1 [..]
1493+
[ERROR] api errors (status 403 Forbidden): you must be logged in
1494+
",
1495+
)
1496+
.run();
1497+
1498+
t.join().unwrap();
1499+
}
1500+
1501+
#[cargo_test]
1502+
fn api_error_code() {
1503+
// Registry returns an error code without a JSON message.
1504+
let t = registry::RegistryBuilder::new().build_api_server(&|_headers| (400, &"go away"));
1505+
1506+
let p = project()
1507+
.file(
1508+
"Cargo.toml",
1509+
r#"
1510+
[project]
1511+
name = "foo"
1512+
version = "0.0.1"
1513+
authors = []
1514+
license = "MIT"
1515+
description = "foo"
1516+
documentation = "foo"
1517+
homepage = "foo"
1518+
repository = "foo"
1519+
"#,
1520+
)
1521+
.file("src/lib.rs", "")
1522+
.build();
1523+
1524+
p.cargo("publish --no-verify --registry alternative")
1525+
.with_status(101)
1526+
.with_stderr(
1527+
"\
1528+
[UPDATING] [..]
1529+
[PACKAGING] foo v0.0.1 [..]
1530+
[UPLOADING] foo v0.0.1 [..]
1531+
[ERROR] failed to get a 200 OK response, got 400
1532+
headers:
1533+
<tab>HTTP/1.1 400
1534+
<tab>Content-Length: 7
1535+
<tab>
1536+
body:
1537+
go away
1538+
",
1539+
)
1540+
.run();
1541+
1542+
t.join().unwrap();
1543+
}
1544+
1545+
#[cargo_test]
1546+
fn api_curl_error() {
1547+
// Registry has a network error.
1548+
let t = registry::RegistryBuilder::new().build_api_server(&|_headers| panic!("broke!"));
1549+
1550+
let p = project()
1551+
.file(
1552+
"Cargo.toml",
1553+
r#"
1554+
[project]
1555+
name = "foo"
1556+
version = "0.0.1"
1557+
authors = []
1558+
license = "MIT"
1559+
description = "foo"
1560+
documentation = "foo"
1561+
homepage = "foo"
1562+
repository = "foo"
1563+
"#,
1564+
)
1565+
.file("src/lib.rs", "")
1566+
.build();
1567+
1568+
// This doesn't check for the exact text of the error in the remote
1569+
// possibility that cargo is linked with a weird version of libcurl, or
1570+
// curl changes the text of the message. Currently the message 52
1571+
// (CURLE_GOT_NOTHING) is:
1572+
// Server returned nothing (no headers, no data) (Empty reply from server)
1573+
p.cargo("publish --no-verify --registry alternative")
1574+
.with_status(101)
1575+
.with_stderr(
1576+
"\
1577+
[UPDATING] [..]
1578+
[PACKAGING] foo v0.0.1 [..]
1579+
[UPLOADING] foo v0.0.1 [..]
1580+
[ERROR] [52] [..]
1581+
",
1582+
)
1583+
.run();
1584+
1585+
let e = t.join().unwrap_err();
1586+
assert_eq!(*e.downcast::<&str>().unwrap(), "broke!");
1587+
}
1588+
1589+
#[cargo_test]
1590+
fn api_other_error() {
1591+
// Registry returns an invalid response.
1592+
let t = registry::RegistryBuilder::new().build_api_server(&|_headers| (200, b"\xff"));
1593+
1594+
let p = project()
1595+
.file(
1596+
"Cargo.toml",
1597+
r#"
1598+
[project]
1599+
name = "foo"
1600+
version = "0.0.1"
1601+
authors = []
1602+
license = "MIT"
1603+
description = "foo"
1604+
documentation = "foo"
1605+
homepage = "foo"
1606+
repository = "foo"
1607+
"#,
1608+
)
1609+
.file("src/lib.rs", "")
1610+
.build();
1611+
1612+
p.cargo("publish --no-verify --registry alternative")
1613+
.with_status(101)
1614+
.with_stderr(
1615+
"\
1616+
[UPDATING] [..]
1617+
[PACKAGING] foo v0.0.1 [..]
1618+
[UPLOADING] foo v0.0.1 [..]
1619+
[ERROR] invalid response from server
1620+
1621+
Caused by:
1622+
response body was not valid utf-8
1623+
",
1624+
)
1625+
.run();
1626+
1627+
t.join().unwrap();
1628+
}

0 commit comments

Comments
 (0)